diff --git a/config/queue-autoscale.php b/config/queue-autoscale.php index b91ebe2..9433edd 100644 --- a/config/queue-autoscale.php +++ b/config/queue-autoscale.php @@ -33,6 +33,18 @@ | Each value can be a ProfileContract class OR an array of partial overrides | that merges with sla_defaults. | + | Optional 'resources' key declares per-queue CPU/memory estimates for + | capacity calculations. These override the global limits when measured + | data is not yet available (cold start). Once the autoscaler has enough + | measured samples, measured values take precedence automatically. + | + | 'slow' => [ + | 'resources' => [ + | 'cpu_cores' => 0.5, // CPU cores per worker (default: limits.worker_cpu_core_estimate) + | 'memory_mb' => 2048, // Memory MB per worker (default: limits.worker_memory_mb_estimate) + | ], + | ], + | */ 'queues' => [ // 'payments' => CriticalProfile::class, diff --git a/docs/advanced-usage/custom-strategies.md b/docs/advanced-usage/custom-strategies.md index 02536c0..9fcc0cc 100644 --- a/docs/advanced-usage/custom-strategies.md +++ b/docs/advanced-usage/custom-strategies.md @@ -669,7 +669,8 @@ use Cbox\LaravelQueueAutoscale\Scaling\ScalingEngine; it('integrates with scaling engine', function () { $strategy = new SimpleRateBasedStrategy(); $capacity = app(\Cbox\LaravelQueueAutoscale\Scaling\Calculators\CapacityCalculator::class); - $engine = new ScalingEngine($strategy, $capacity); + $resolver = app(\Cbox\LaravelQueueAutoscale\Scaling\ResourceEstimateResolver::class); + $engine = new ScalingEngine($strategy, $capacity, $resolver); $metrics = (object) [ 'processingRate' => 10.0, diff --git a/src/Configuration/AutoscaleConfiguration.php b/src/Configuration/AutoscaleConfiguration.php index 286fe8b..a0bd444 100644 --- a/src/Configuration/AutoscaleConfiguration.php +++ b/src/Configuration/AutoscaleConfiguration.php @@ -339,6 +339,38 @@ public static function configuredQueues(): array return $result; } + /** + * Get per-queue resource overrides. + * + * Returns the 'resources' sub-array from the queue's config entry, + * or an empty array if not configured. Valid keys: 'cpu_cores', 'memory_mb'. + * Unknown keys are preserved for forward compatibility. + * + * @return array + */ + public static function queueResources(string $queue): array + { + $queueConfig = config("queue-autoscale.queues.{$queue}"); + + if (! is_array($queueConfig)) { + return []; + } + + $resources = $queueConfig['resources'] ?? null; + + if (! is_array($resources)) { + return []; + } + + /** @var array $filtered */ + $filtered = array_filter( + $resources, + static fn (mixed $value): bool => is_int($value) || is_float($value), + ); + + return $filtered; + } + private static function intConfig(string $key, int $default): int { return self::intValue(config($key, $default), $default); diff --git a/src/LaravelQueueAutoscaleServiceProvider.php b/src/LaravelQueueAutoscaleServiceProvider.php index f91ab9a..bae89d4 100644 --- a/src/LaravelQueueAutoscaleServiceProvider.php +++ b/src/LaravelQueueAutoscaleServiceProvider.php @@ -32,6 +32,7 @@ use Cbox\LaravelQueueAutoscale\Scaling\Calculators\LinearRegressionForecaster; use Cbox\LaravelQueueAutoscale\Scaling\Calculators\LittlesLawCalculator; use Cbox\LaravelQueueAutoscale\Scaling\Forecasting\Policies\ModerateForecastPolicy; +use Cbox\LaravelQueueAutoscale\Scaling\ResourceEstimateResolver; use Cbox\LaravelQueueAutoscale\Scaling\ScalingEngine; use Cbox\LaravelQueueAutoscale\Support\ManagerProcessLock; use Cbox\LaravelQueueAutoscale\Support\RestartSignal; @@ -67,6 +68,7 @@ public function register(): void $this->app->singleton(LittlesLawCalculator::class); $this->app->singleton(BacklogDrainCalculator::class); $this->app->singleton(CapacityCalculator::class); + $this->app->singleton(ResourceEstimateResolver::class); $this->app->singleton(ArrivalRateEstimator::class); // Register v2 contracts with their default implementations diff --git a/src/Manager/AutoscaleManager.php b/src/Manager/AutoscaleManager.php index 66a5768..74f8be7 100644 --- a/src/Manager/AutoscaleManager.php +++ b/src/Manager/AutoscaleManager.php @@ -27,6 +27,8 @@ use Cbox\LaravelQueueAutoscale\Output\DataTransferObjects\WorkerStatus; use Cbox\LaravelQueueAutoscale\Policies\PolicyExecutor; use Cbox\LaravelQueueAutoscale\Scaling\Calculators\CapacityCalculator; +use Cbox\LaravelQueueAutoscale\Scaling\DTOs\ResourceEstimate; +use Cbox\LaravelQueueAutoscale\Scaling\ResourceEstimateResolver; use Cbox\LaravelQueueAutoscale\Scaling\ScalingDecision; use Cbox\LaravelQueueAutoscale\Scaling\ScalingEngine; use Cbox\LaravelQueueAutoscale\Support\RestartSignal; @@ -105,6 +107,7 @@ public function __construct( private readonly RestartSignal $restartSignal, private readonly ClusterStore $clusterStore, private readonly CapacityCalculator $capacity, + private readonly ResourceEstimateResolver $resolver, ) { $this->pool = new WorkerPool; $this->outputBuffer = new WorkerOutputBuffer; @@ -251,7 +254,10 @@ private function runLoop(): void private function runClusterCycle(): void { - $capacity = $this->capacity->calculateMaxWorkers($this->pool->totalCount()); + $capacity = $this->capacity->calculateMaxWorkers( + $this->pool->totalCount(), + ResourceEstimate::globalDefault(), + ); $capacityDetails = $capacity->details; $cpuDetails = is_array($capacityDetails['cpu_details'] ?? null) ? $capacityDetails['cpu_details'] : []; $memoryDetails = is_array($capacityDetails['memory_details'] ?? null) ? $capacityDetails['memory_details'] : []; @@ -315,7 +321,7 @@ private function evaluateAndPublishClusterRecommendations(): void { app(CalculateQueueMetricsAction::class)->executeForAllQueues(); - $this->updateMeasuredWorkerCpuEstimate(); + $this->updateMeasuredResourceEstimates(); $allQueues = QueueMetrics::getAllQueuesWithMetrics(); @@ -928,7 +934,7 @@ private function evaluateAndScale(): void // Recalculate metrics first to ensure throughput uses current sliding window app(CalculateQueueMetricsAction::class)->executeForAllQueues(); - $this->updateMeasuredWorkerCpuEstimate(); + $this->updateMeasuredResourceEstimates(); // Get ALL queues with metrics from laravel-queue-metrics // Returns: ['redis:default' => [...metrics array...], ...] @@ -1066,21 +1072,14 @@ private function announceExclusion(string $connection, string $queue): void } /** - * Minimum measured CPU core estimate to prevent runaway capacity calculations. - * A worker using less than 1% of a core during processing is implausible; - * clamping here avoids division producing thousands of workers from noise. - */ - private const MIN_MEASURED_CPU_CORE_ESTIMATE = 0.01; - - /** - * Compute measured CPU core estimate from actual job processing metrics. + * Compute per-queue measured CPU and memory estimates from actual job metrics. * - * Uses the ratio cpuTimeMs / durationMs across all job classes to determine - * how much CPU each worker actually uses during processing. This pessimistic - * estimate (based on active processing, not idle time) ensures capacity - * planning accounts for worst-case worker CPU load. + * Walks QueueMetrics::getAllJobsWithMetrics() and groups results by + * connection:queue. For each queue, calculates weighted average CPU cores + * (cpuTimeMs / durationMs) and weighted average memory. Results are pushed + * into the ResourceEstimateResolver for use by ScalingEngine. */ - private function updateMeasuredWorkerCpuEstimate(): void + private function updateMeasuredResourceEstimates(): void { try { $allJobs = QueueMetrics::getAllJobsWithMetrics(); @@ -1088,29 +1087,83 @@ private function updateMeasuredWorkerCpuEstimate(): void return; } - $totalWeightedCpuCores = 0.0; - $totalProcessed = 0; + /** @var array $perQueue */ + $perQueue = []; foreach ($allJobs as $jobData) { + $connection = is_string($jobData['connection'] ?? null) ? $jobData['connection'] : 'default'; + $queue = is_string($jobData['queue'] ?? null) ? $jobData['queue'] : 'default'; + $key = "{$connection}:{$queue}"; + $cpu = is_array($jobData['cpu'] ?? null) ? $jobData['cpu'] : []; $duration = is_array($jobData['duration'] ?? null) ? $jobData['duration'] : []; + $memory = is_array($jobData['memory'] ?? null) ? $jobData['memory'] : []; $execution = is_array($jobData['execution'] ?? null) ? $jobData['execution'] : []; $cpuAvgMs = is_numeric($cpu['avg'] ?? null) ? (float) $cpu['avg'] : 0.0; $durationAvgMs = is_numeric($duration['avg'] ?? null) ? (float) $duration['avg'] : 0.0; + $memAvgMb = is_numeric($memory['avg'] ?? null) ? (float) $memory['avg'] : 0.0; $processed = is_numeric($execution['total_processed'] ?? null) ? (int) $execution['total_processed'] : 0; - if ($durationAvgMs > 0 && $cpuAvgMs > 0 && $processed > 0) { + if ($processed <= 0) { + continue; + } + + if (! isset($perQueue[$key])) { + $perQueue[$key] = ['cpu_weighted' => 0.0, 'mem_weighted' => 0.0, 'cpu_processed' => 0, 'mem_processed' => 0]; + } + + if ($durationAvgMs > 0 && $cpuAvgMs > 0) { $coresPerWorker = $cpuAvgMs / $durationAvgMs; - $totalWeightedCpuCores += $coresPerWorker * $processed; - $totalProcessed += $processed; + $perQueue[$key]['cpu_weighted'] += $coresPerWorker * $processed; + $perQueue[$key]['cpu_processed'] += $processed; + } + + if ($memAvgMb > 0) { + $perQueue[$key]['mem_weighted'] += $memAvgMb * $processed; + $perQueue[$key]['mem_processed'] += $processed; } } - if ($totalProcessed > 0) { - $measuredEstimate = max($totalWeightedCpuCores / $totalProcessed, self::MIN_MEASURED_CPU_CORE_ESTIMATE); - $this->capacity->setMeasuredWorkerCpuCoreEstimate($measuredEstimate); - $this->verbose(sprintf(' Measured worker CPU: %.3f cores/worker (from %d jobs)', $measuredEstimate, $totalProcessed), 'debug'); + foreach ($perQueue as $key => $data) { + [$connection, $queue] = explode(':', $key, 2); + + $hasCpu = $data['cpu_processed'] > 0; + $hasMemory = $data['mem_processed'] > 0; + + if ($hasCpu && $hasMemory) { + $this->resolver->setMeasured( + $connection, + $queue, + $data['cpu_weighted'] / $data['cpu_processed'], + $data['mem_weighted'] / $data['mem_processed'], + $data['cpu_processed'], + $data['mem_processed'], + ); + } elseif ($hasCpu) { + $this->resolver->setMeasuredCpu( + $connection, + $queue, + $data['cpu_weighted'] / $data['cpu_processed'], + $data['cpu_processed'], + ); + } elseif ($hasMemory) { + $this->resolver->setMeasuredMemory( + $connection, + $queue, + $data['mem_weighted'] / $data['mem_processed'], + $data['mem_processed'], + ); + } + + $this->verbose(sprintf( + ' Measured resources [%s]: cpu=%.3f cores (%d samples), mem=%.1f MB (%d samples)', + $key, + $hasCpu ? $data['cpu_weighted'] / $data['cpu_processed'] : 0.0, + $data['cpu_processed'], + $hasMemory ? $data['mem_weighted'] / $data['mem_processed'] : 0.0, + $data['mem_processed'], + ), 'debug'); } } diff --git a/src/Policies/NoScaleDownPolicy.php b/src/Policies/NoScaleDownPolicy.php index 8feec13..fa8c635 100644 --- a/src/Policies/NoScaleDownPolicy.php +++ b/src/Policies/NoScaleDownPolicy.php @@ -6,6 +6,7 @@ use Cbox\LaravelQueueAutoscale\Contracts\ScalingPolicy; use Cbox\LaravelQueueAutoscale\Scaling\Calculators\CapacityCalculator; +use Cbox\LaravelQueueAutoscale\Scaling\DTOs\ResourceEstimate; use Cbox\LaravelQueueAutoscale\Scaling\ScalingDecision; /** @@ -43,7 +44,10 @@ public function beforeScaling(ScalingDecision $decision): ?ScalingDecision // Check if scale-down is forced by resource constraints // If current workers exceed system capacity, we must scale down for stability - $capacityResult = $this->capacity->calculateMaxWorkers(); + $capacityResult = $this->capacity->calculateMaxWorkers( + $decision->currentWorkers, + ResourceEstimate::globalDefault(), + ); if ($decision->currentWorkers > $capacityResult->finalMaxWorkers) { // Let resource-constrained scale-down proceed to maintain system stability return null; diff --git a/src/Scaling/Calculators/CapacityCalculator.php b/src/Scaling/Calculators/CapacityCalculator.php index 73b1c13..3af100f 100644 --- a/src/Scaling/Calculators/CapacityCalculator.php +++ b/src/Scaling/Calculators/CapacityCalculator.php @@ -6,6 +6,7 @@ use Cbox\LaravelQueueAutoscale\Configuration\AutoscaleConfiguration; use Cbox\LaravelQueueAutoscale\Scaling\DTOs\CapacityCalculationResult; +use Cbox\LaravelQueueAutoscale\Scaling\DTOs\ResourceEstimate; use Cbox\SystemMetrics\SystemMetrics; class CapacityCalculator @@ -25,30 +26,12 @@ class CapacityCalculator private ?float $cacheTimestamp = null; - /** - * Measured CPU core usage per worker derived from actual job processing metrics. - * When set, takes precedence over the config-based estimate. - */ - private ?float $measuredWorkerCpuCoreEstimate = null; - /** * How long cached metrics remain valid (seconds). * Should be shorter than the evaluation interval to ensure fresh data each tick. */ private const CACHE_TTL_SECONDS = 4.0; - /** - * Set measured CPU core estimate from actual job processing data. - * - * This value represents the average fraction of a CPU core each worker - * consumes during job processing (e.g. 0.15 = 15% of one core). - * When set, this takes precedence over the config-based worker_cpu_core_estimate. - */ - public function setMeasuredWorkerCpuCoreEstimate(?float $estimate): void - { - $this->measuredWorkerCpuCoreEstimate = $estimate; - } - /** * Calculate maximum workers with detailed capacity breakdown * @@ -61,7 +44,7 @@ public function setMeasuredWorkerCpuCoreEstimate(?float $estimate): void * @param int $currentWorkers Total workers currently running across all queues (for accurate capacity math) * @return CapacityCalculationResult Detailed capacity analysis with system-wide max workers */ - public function calculateMaxWorkers(int $currentWorkers = 0): CapacityCalculationResult + public function calculateMaxWorkers(int $currentWorkers, ResourceEstimate $estimate): CapacityCalculationResult { // Refresh system metrics if cache is stale or empty if (! $this->isCacheValid()) { @@ -92,11 +75,8 @@ public function calculateMaxWorkers(int $currentWorkers = 0): CapacityCalculatio $reserveCores = AutoscaleConfiguration::reserveCpuCores(); $usableCores = max($this->cachedAvailableCores - $reserveCores, 0); - $workerCpuCoreEstimate = max( - $this->measuredWorkerCpuCoreEstimate ?? AutoscaleConfiguration::workerCpuCoreEstimate(), - 0.01 - ); - $cpuEstimateSource = $this->measuredWorkerCpuCoreEstimate !== null ? 'measured' : 'config'; + $workerCpuCoreEstimate = max($estimate->cpuCoresPerWorker, 0.01); + $cpuEstimateSource = $estimate->cpuSource->value; $availableCoreEquivalents = $usableCores * ($availableCpuPercent / 100); $additionalWorkersByCpu = (int) floor($availableCoreEquivalents / $workerCpuCoreEstimate); @@ -107,7 +87,7 @@ public function calculateMaxWorkers(int $currentWorkers = 0): CapacityCalculatio $currentMemoryPercent = $this->cachedMemoryPercent ?? 50.0; $availableMemoryPercent = max($maxMemoryPercent - $currentMemoryPercent, 0); - $workerMemoryMb = AutoscaleConfiguration::workerMemoryMbEstimate(); + $workerMemoryMb = max($estimate->memoryMbPerWorker, 1.0); $totalMemoryMb = $this->cachedTotalMemoryMb ?? 4096.0; // Calculate additional workers we can add based on available memory @@ -155,6 +135,7 @@ public function calculateMaxWorkers(int $currentWorkers = 0): CapacityCalculatio 'available_memory_percent' => $availableMemoryPercent, 'total_memory_mb' => $totalMemoryMb, 'worker_memory_mb' => $workerMemoryMb, + 'memory_estimate_source' => $estimate->memorySource->value, ], ]; diff --git a/src/Scaling/DTOs/EstimateSource.php b/src/Scaling/DTOs/EstimateSource.php new file mode 100644 index 0000000..bdafc95 --- /dev/null +++ b/src/Scaling/DTOs/EstimateSource.php @@ -0,0 +1,12 @@ + + */ + private array $measured = []; + + public function resolve(string $connection, string $queue): ResourceEstimate + { + $key = "{$connection}:{$queue}"; + $measuredData = $this->measured[$key] ?? []; + $configResources = AutoscaleConfiguration::queueResources($queue); + + // Resolve CPU: measured > config > global + $cpuResult = $this->resolveDimension( + measured: $measuredData['cpu'] ?? null, + config: is_numeric($configResources['cpu_cores'] ?? null) ? (float) $configResources['cpu_cores'] : null, + default: AutoscaleConfiguration::workerCpuCoreEstimate(), + measuredSamples: $measuredData['cpu_samples'] ?? null, + ); + + // Resolve memory: measured > config > global + $memoryResult = $this->resolveDimension( + measured: $measuredData['memory'] ?? null, + config: is_numeric($configResources['memory_mb'] ?? null) ? (float) $configResources['memory_mb'] : null, + default: (float) AutoscaleConfiguration::workerMemoryMbEstimate(), + measuredSamples: $measuredData['memory_samples'] ?? null, + ); + + return new ResourceEstimate( + cpuCoresPerWorker: max($cpuResult['value'], self::MIN_CPU_CORES), + memoryMbPerWorker: max($memoryResult['value'], self::MIN_MEMORY_MB), + cpuSource: $cpuResult['source'], + memorySource: $memoryResult['source'], + cpuSampleCount: $cpuResult['samples'], + memorySampleCount: $memoryResult['samples'], + ); + } + + public function setMeasured( + string $connection, + string $queue, + float $cpuCoresPerWorker, + float $memoryMbPerWorker, + int $cpuSampleCount, + int $memorySampleCount, + ): void { + $key = "{$connection}:{$queue}"; + $this->measured[$key] = [ + 'cpu' => $cpuCoresPerWorker, + 'memory' => $memoryMbPerWorker, + 'cpu_samples' => $cpuSampleCount, + 'memory_samples' => $memorySampleCount, + ]; + } + + public function setMeasuredCpu(string $connection, string $queue, float $cpuCoresPerWorker, int $sampleCount): void + { + $key = "{$connection}:{$queue}"; + $this->measured[$key]['cpu'] = $cpuCoresPerWorker; + $this->measured[$key]['cpu_samples'] = $sampleCount; + } + + public function setMeasuredMemory(string $connection, string $queue, float $memoryMbPerWorker, int $sampleCount): void + { + $key = "{$connection}:{$queue}"; + $this->measured[$key]['memory'] = $memoryMbPerWorker; + $this->measured[$key]['memory_samples'] = $sampleCount; + } + + public function reset(): void + { + $this->measured = []; + } + + /** + * @return array{value: float, source: EstimateSource, samples: int|null} + */ + private function resolveDimension(?float $measured, ?float $config, float $default, ?int $measuredSamples): array + { + if ($measured !== null) { + return [ + 'value' => $measured, + 'source' => EstimateSource::Measured, + 'samples' => $measuredSamples, + ]; + } + + if ($config !== null) { + return [ + 'value' => $config, + 'source' => EstimateSource::Config, + 'samples' => null, + ]; + } + + return [ + 'value' => $default, + 'source' => EstimateSource::Default, + 'samples' => null, + ]; + } +} diff --git a/src/Scaling/ScalingEngine.php b/src/Scaling/ScalingEngine.php index 8f560b8..a0763b8 100644 --- a/src/Scaling/ScalingEngine.php +++ b/src/Scaling/ScalingEngine.php @@ -15,6 +15,7 @@ public function __construct( private ScalingStrategyContract $strategy, private CapacityCalculator $capacity, + private ResourceEstimateResolver $resolver, ) {} /** @@ -48,7 +49,8 @@ public function evaluate( // Note: both $totalPoolWorkers and calculateMaxWorkers() must be local-host // scoped. In cluster mode, use evaluateDemand() instead. $effectiveTotalWorkers = max($totalPoolWorkers, $currentWorkers); - $capacityResult = $this->capacity->calculateMaxWorkers($effectiveTotalWorkers); + $estimate = $this->resolver->resolve($config->connection, $config->queue); + $capacityResult = $this->capacity->calculateMaxWorkers($effectiveTotalWorkers, $estimate); // 3. Apply resource constraints: this queue's share of system capacity // System can support capacityResult->finalMaxWorkers total. Other queues diff --git a/tests/Feature/ScalingIntegrationTest.php b/tests/Feature/ScalingIntegrationTest.php index 94fc2f9..80a74dd 100644 --- a/tests/Feature/ScalingIntegrationTest.php +++ b/tests/Feature/ScalingIntegrationTest.php @@ -6,6 +6,7 @@ use Cbox\LaravelQueueAutoscale\Events\ScalingDecisionMade; use Cbox\LaravelQueueAutoscale\Events\WorkersScaled; use Cbox\LaravelQueueAutoscale\Scaling\Calculators\CapacityCalculator; +use Cbox\LaravelQueueAutoscale\Scaling\ResourceEstimateResolver; use Cbox\LaravelQueueAutoscale\Scaling\ScalingDecision; use Cbox\LaravelQueueAutoscale\Scaling\ScalingEngine; use Illuminate\Support\Facades\Event; @@ -67,7 +68,7 @@ public function getLastPrediction(): ?float // Create engine with custom strategy $capacity = app(CapacityCalculator::class); - $customEngine = new ScalingEngine($customStrategy, $capacity); + $customEngine = new ScalingEngine($customStrategy, $capacity, new ResourceEstimateResolver); $metrics = createMetrics([ 'throughput_per_minute' => 600.0, // 10.0 jobs/sec * 60 diff --git a/tests/Simulation/ScalingSimulation.php b/tests/Simulation/ScalingSimulation.php index e557e7c..969a199 100644 --- a/tests/Simulation/ScalingSimulation.php +++ b/tests/Simulation/ScalingSimulation.php @@ -18,6 +18,7 @@ use Cbox\LaravelQueueAutoscale\Scaling\Calculators\LinearRegressionForecaster; use Cbox\LaravelQueueAutoscale\Scaling\Calculators\LittlesLawCalculator; use Cbox\LaravelQueueAutoscale\Scaling\Forecasting\Policies\ModerateForecastPolicy; +use Cbox\LaravelQueueAutoscale\Scaling\ResourceEstimateResolver; use Cbox\LaravelQueueAutoscale\Scaling\ScalingDecision; use Cbox\LaravelQueueAutoscale\Scaling\ScalingEngine; use Cbox\LaravelQueueAutoscale\Scaling\Strategies\HybridStrategy; @@ -126,7 +127,7 @@ public function recentSamples(string $connection, string $queue, int $windowSeco percentileCalc: new SortBasedPercentileCalculator, ); - $this->engine = new ScalingEngine($strategy, new UnlimitedCapacityCalculator); + $this->engine = new ScalingEngine($strategy, new UnlimitedCapacityCalculator, new ResourceEstimateResolver); } /** diff --git a/tests/Simulation/UnlimitedCapacityCalculator.php b/tests/Simulation/UnlimitedCapacityCalculator.php index 00db4f0..9d9d59b 100644 --- a/tests/Simulation/UnlimitedCapacityCalculator.php +++ b/tests/Simulation/UnlimitedCapacityCalculator.php @@ -6,6 +6,7 @@ use Cbox\LaravelQueueAutoscale\Scaling\Calculators\CapacityCalculator; use Cbox\LaravelQueueAutoscale\Scaling\DTOs\CapacityCalculationResult; +use Cbox\LaravelQueueAutoscale\Scaling\DTOs\ResourceEstimate; /** * Capacity calculator that always returns unlimited capacity. @@ -15,7 +16,7 @@ */ final class UnlimitedCapacityCalculator extends CapacityCalculator { - public function calculateMaxWorkers(int $currentWorkers = 0): CapacityCalculationResult + public function calculateMaxWorkers(int $currentWorkers, ResourceEstimate $estimate): CapacityCalculationResult { return new CapacityCalculationResult( maxWorkersByCpu: PHP_INT_MAX, diff --git a/tests/Unit/CapacityCalculatorTest.php b/tests/Unit/CapacityCalculatorTest.php index 380b4e0..55e0ae4 100644 --- a/tests/Unit/CapacityCalculatorTest.php +++ b/tests/Unit/CapacityCalculatorTest.php @@ -4,6 +4,7 @@ use Cbox\LaravelQueueAutoscale\Scaling\Calculators\CapacityCalculator; use Cbox\LaravelQueueAutoscale\Scaling\DTOs\CapacityCalculationResult; +use Cbox\LaravelQueueAutoscale\Scaling\DTOs\ResourceEstimate; /** * Note: CapacityCalculator uses SystemMetrics which queries actual system state. @@ -13,7 +14,7 @@ it('returns capacity calculation result with detailed breakdown', function () { $calculator = new CapacityCalculator; - $result = $calculator->calculateMaxWorkers(); + $result = $calculator->calculateMaxWorkers(0, ResourceEstimate::globalDefault()); expect($result)->toBeInstanceOf(CapacityCalculationResult::class) ->and($result->finalMaxWorkers)->toBeInt() @@ -29,7 +30,7 @@ // We can't easily force SystemMetrics::limits() to fail in tests, // but we verify the method doesn't throw exceptions - $result = $calculator->calculateMaxWorkers(); + $result = $calculator->calculateMaxWorkers(0, ResourceEstimate::globalDefault()); expect($result)->toBeInstanceOf(CapacityCalculationResult::class) ->and($result->finalMaxWorkers)->toBeInt() @@ -40,10 +41,10 @@ $calculator = new CapacityCalculator; // First calculation - $result1 = $calculator->calculateMaxWorkers(); + $result1 = $calculator->calculateMaxWorkers(0, ResourceEstimate::globalDefault()); // Second calculation (should be consistent in stable system) - $result2 = $calculator->calculateMaxWorkers(); + $result2 = $calculator->calculateMaxWorkers(0, ResourceEstimate::globalDefault()); expect($result1->finalMaxWorkers)->toBeInt() ->and($result2->finalMaxWorkers)->toBeInt() @@ -54,7 +55,7 @@ it('respects system resource constraints', function () { $calculator = new CapacityCalculator; - $result = $calculator->calculateMaxWorkers(); + $result = $calculator->calculateMaxWorkers(0, ResourceEstimate::globalDefault()); // Max workers should be reasonable (not millions) // This validates the calculation uses constraints properly @@ -64,7 +65,7 @@ it('provides detailed capacity breakdown with explanations', function () { $calculator = new CapacityCalculator; - $result = $calculator->calculateMaxWorkers(); + $result = $calculator->calculateMaxWorkers(0, ResourceEstimate::globalDefault()); expect($result->details)->toBeArray() ->and($result->details)->toHaveKey('cpu_explanation') @@ -76,7 +77,7 @@ it('identifies limiting factor correctly', function () { $calculator = new CapacityCalculator; - $result = $calculator->calculateMaxWorkers(); + $result = $calculator->calculateMaxWorkers(0, ResourceEstimate::globalDefault()); // Limiting factor should be one of: cpu, memory, balanced expect($result->limitingFactor)->toBeIn(['cpu', 'memory', 'balanced']); @@ -85,7 +86,7 @@ it('provides helper methods for limiting factor checks', function () { $calculator = new CapacityCalculator; - $result = $calculator->calculateMaxWorkers(); + $result = $calculator->calculateMaxWorkers(0, ResourceEstimate::globalDefault()); // One of the helper methods should return true (unless 'balanced') if ($result->limitingFactor !== 'balanced') { @@ -97,7 +98,7 @@ it('provides human-readable summary', function () { $calculator = new CapacityCalculator; - $result = $calculator->calculateMaxWorkers(); + $result = $calculator->calculateMaxWorkers(0, ResourceEstimate::globalDefault()); $summary = $result->getSummary(); @@ -109,7 +110,7 @@ it('provides formatted details for verbose output', function () { $calculator = new CapacityCalculator; - $result = $calculator->calculateMaxWorkers(); + $result = $calculator->calculateMaxWorkers(0, ResourceEstimate::globalDefault()); $formatted = $result->getFormattedDetails(); @@ -123,7 +124,7 @@ it('includes worker_cpu_core_estimate in cpu_details', function () { $calculator = new CapacityCalculator; - $result = $calculator->calculateMaxWorkers(); + $result = $calculator->calculateMaxWorkers(0, ResourceEstimate::globalDefault()); expect($result->details['cpu_details']) ->toHaveKey('worker_cpu_core_estimate') @@ -131,24 +132,21 @@ }); it('allows more workers with lower worker_cpu_core_estimate', function () { - // Eliminate environment dependencies: no reserve, allow full CPU. config()->set('queue-autoscale.limits.max_cpu_percent', 100); config()->set('queue-autoscale.limits.reserve_cpu_cores', 0); - config()->set('queue-autoscale.limits.worker_cpu_core_estimate', 1.0); $calculator = new CapacityCalculator; - $highEstimate = $calculator->calculateMaxWorkers(); - // Reuse cached metrics — only the config value changes. - config()->set('queue-autoscale.limits.worker_cpu_core_estimate', 0.2); - $lowEstimate = $calculator->calculateMaxWorkers(); + $highEstimate = ResourceEstimate::fromConfig(1.0, 128.0); + $highResult = $calculator->calculateMaxWorkers(0, $highEstimate); - // On CI runners where system-metrics reports 0 cores (no cgroup limit), - // both estimates yield 0 workers — verify estimate is applied instead. - if ($highEstimate->maxWorkersByCpu > 0) { - expect($lowEstimate->maxWorkersByCpu)->toBeGreaterThan($highEstimate->maxWorkersByCpu); + $lowEstimate = ResourceEstimate::fromConfig(0.2, 128.0); + $lowResult = $calculator->calculateMaxWorkers(0, $lowEstimate); + + if ($highResult->maxWorkersByCpu > 0) { + expect($lowResult->maxWorkersByCpu)->toBeGreaterThan($highResult->maxWorkersByCpu); } else { - expect($lowEstimate->details['cpu_details']['worker_cpu_core_estimate'])->toBe(0.2) - ->and($highEstimate->details['cpu_details']['worker_cpu_core_estimate'])->toBe(1.0); + expect($lowEstimate->cpuCoresPerWorker)->toBe(0.2) + ->and($highEstimate->cpuCoresPerWorker)->toBe(1.0); } }); @@ -156,49 +154,44 @@ config()->offsetUnset('queue-autoscale.limits.worker_cpu_core_estimate'); $calculator = new CapacityCalculator; - $result = $calculator->calculateMaxWorkers(); + $result = $calculator->calculateMaxWorkers(0, ResourceEstimate::globalDefault()); expect($result->details['cpu_details']['worker_cpu_core_estimate'])->toBe(0.2) - ->and($result->details['cpu_details']['cpu_estimate_source'])->toBe('config'); + ->and($result->details['cpu_details']['cpu_estimate_source'])->toBe('default'); }); it('uses measured CPU estimate when set, overriding config', function () { - // Eliminate environment dependencies: no reserve, allow full CPU. config()->set('queue-autoscale.limits.max_cpu_percent', 100); config()->set('queue-autoscale.limits.reserve_cpu_cores', 0); - config()->set('queue-autoscale.limits.worker_cpu_core_estimate', 1.0); $calculator = new CapacityCalculator; - $configResult = $calculator->calculateMaxWorkers(); + $configEstimate = ResourceEstimate::fromConfig(1.0, 128.0); + $configResult = $calculator->calculateMaxWorkers(0, $configEstimate); - // Reuse cached metrics — only the estimate changes. - $calculator->setMeasuredWorkerCpuCoreEstimate(0.1); - $measuredResult = $calculator->calculateMaxWorkers(); + $measuredEstimate = ResourceEstimate::measured(0.1, 128.0, 500, 500); + $measuredResult = $calculator->calculateMaxWorkers(0, $measuredEstimate); - // Estimate is correctly applied regardless of available capacity. expect($measuredResult->details['cpu_details']['worker_cpu_core_estimate'])->toBe(0.1) ->and($measuredResult->details['cpu_details']['cpu_estimate_source'])->toBe('measured'); - // When system has detectable CPU cores, lower estimate yields more workers. if ($configResult->maxWorkersByCpu > 0) { expect($measuredResult->maxWorkersByCpu)->toBeGreaterThan($configResult->maxWorkersByCpu); } }); -it('falls back to config when measured estimate is cleared', function () { +it('uses different estimates producing different results', function () { config()->set('queue-autoscale.limits.worker_cpu_core_estimate', 0.5); $calculator = new CapacityCalculator; - $calculator->setMeasuredWorkerCpuCoreEstimate(0.1); - $calculator->invalidateCache(); - $measuredResult = $calculator->calculateMaxWorkers(); + $measuredEstimate = ResourceEstimate::measured(0.1, 128.0, 500, 500); + $measuredResult = $calculator->calculateMaxWorkers(0, $measuredEstimate); - $calculator->setMeasuredWorkerCpuCoreEstimate(null); + $configEstimate = ResourceEstimate::globalDefault(); $calculator->invalidateCache(); - $configResult = $calculator->calculateMaxWorkers(); + $configResult = $calculator->calculateMaxWorkers(0, $configEstimate); expect($measuredResult->details['cpu_details']['cpu_estimate_source'])->toBe('measured') - ->and($configResult->details['cpu_details']['cpu_estimate_source'])->toBe('config') + ->and($configResult->details['cpu_details']['cpu_estimate_source'])->toBe('default') ->and($configResult->details['cpu_details']['worker_cpu_core_estimate'])->toBe(0.5); }); @@ -207,12 +200,12 @@ // First call - measures system metrics (expensive, ~1s for CPU) $start = microtime(true); - $result1 = $calculator->calculateMaxWorkers(5); + $result1 = $calculator->calculateMaxWorkers(5, ResourceEstimate::globalDefault()); $firstCallDuration = microtime(true) - $start; // Second call - should use cached metrics (fast) $start = microtime(true); - $result2 = $calculator->calculateMaxWorkers(5); + $result2 = $calculator->calculateMaxWorkers(5, ResourceEstimate::globalDefault()); $secondCallDuration = microtime(true) - $start; // Second call should be significantly faster (cached, no 1s CPU measurement) @@ -228,17 +221,42 @@ $calculator = new CapacityCalculator; // First call caches metrics - $calculator->calculateMaxWorkers(); + $calculator->calculateMaxWorkers(0, ResourceEstimate::globalDefault()); // Invalidate the cache $calculator->invalidateCache(); // Next call should refresh metrics (will take ~1s for CPU measurement) $start = microtime(true); - $result = $calculator->calculateMaxWorkers(); + $result = $calculator->calculateMaxWorkers(0, ResourceEstimate::globalDefault()); $duration = microtime(true) - $start; expect($result)->toBeInstanceOf(CapacityCalculationResult::class) // Should have taken measurable time for fresh CPU measurement ->and($duration)->toBeGreaterThan(0.5); }); + +it('includes memory_estimate_source in memory_details', function () { + $calculator = new CapacityCalculator; + + $result = $calculator->calculateMaxWorkers(0, ResourceEstimate::globalDefault()); + + expect($result->details['memory_details']) + ->toHaveKey('memory_estimate_source') + ->and($result->details['memory_details']['memory_estimate_source'])->toBe('default'); +}); + +it('uses per-queue memory estimate from ResourceEstimate', function () { + config()->set('queue-autoscale.limits.max_memory_percent', 100); + $calculator = new CapacityCalculator; + + $smallEstimate = ResourceEstimate::fromConfig(0.2, 50.0); + $smallResult = $calculator->calculateMaxWorkers(0, $smallEstimate); + + $largeEstimate = ResourceEstimate::fromConfig(0.2, 2048.0); + $largeResult = $calculator->calculateMaxWorkers(0, $largeEstimate); + + if ($largeResult->maxWorkersByMemory > 0) { + expect($smallResult->maxWorkersByMemory)->toBeGreaterThan($largeResult->maxWorkersByMemory); + } +}); diff --git a/tests/Unit/Configuration/AutoscaleConfigurationQueuesTest.php b/tests/Unit/Configuration/AutoscaleConfigurationQueuesTest.php index 3b9bf62..e11f630 100644 --- a/tests/Unit/Configuration/AutoscaleConfigurationQueuesTest.php +++ b/tests/Unit/Configuration/AutoscaleConfigurationQueuesTest.php @@ -34,3 +34,53 @@ InvalidArgumentException::class, 'queue-autoscale.queues' ); + +it('returns empty array when queue has no resources configured', function (): void { + config()->set('queue-autoscale.queues', [ + 'fast' => ['sla' => ['target_seconds' => 10]], + ]); + + $resources = AutoscaleConfiguration::queueResources('fast'); + + expect($resources)->toBe([]); +}); + +it('returns configured resources for a queue', function (): void { + config()->set('queue-autoscale.queues', [ + 'slow' => [ + 'resources' => [ + 'cpu_cores' => 0.5, + 'memory_mb' => 2048, + ], + ], + ]); + + $resources = AutoscaleConfiguration::queueResources('slow'); + + expect($resources)->toBe([ + 'cpu_cores' => 0.5, + 'memory_mb' => 2048, + ]); +}); + +it('returns partial resources when only one dimension configured', function (): void { + config()->set('queue-autoscale.queues', [ + 'heavy' => [ + 'resources' => [ + 'memory_mb' => 4096, + ], + ], + ]); + + $resources = AutoscaleConfiguration::queueResources('heavy'); + + expect($resources)->toBe(['memory_mb' => 4096]); +}); + +it('returns empty array when queue is not configured at all', function (): void { + config()->set('queue-autoscale.queues', []); + + $resources = AutoscaleConfiguration::queueResources('nonexistent'); + + expect($resources)->toBe([]); +}); diff --git a/tests/Unit/Scaling/DTOs/ResourceEstimateTest.php b/tests/Unit/Scaling/DTOs/ResourceEstimateTest.php new file mode 100644 index 0000000..2d633cd --- /dev/null +++ b/tests/Unit/Scaling/DTOs/ResourceEstimateTest.php @@ -0,0 +1,75 @@ +cpuCoresPerWorker)->toBe(0.15) + ->and($estimate->memoryMbPerWorker)->toBe(256.0) + ->and($estimate->cpuSource)->toBe(EstimateSource::Measured) + ->and($estimate->memorySource)->toBe(EstimateSource::Config) + ->and($estimate->cpuSampleCount)->toBe(500) + ->and($estimate->memorySampleCount)->toBeNull(); +}); + +it('creates global default from config values', function () { + config()->set('queue-autoscale.limits.worker_cpu_core_estimate', 0.3); + config()->set('queue-autoscale.limits.worker_memory_mb_estimate', 256); + + $estimate = ResourceEstimate::globalDefault(); + + expect($estimate->cpuCoresPerWorker)->toBe(0.3) + ->and($estimate->memoryMbPerWorker)->toBe(256.0) + ->and($estimate->cpuSource)->toBe(EstimateSource::Default) + ->and($estimate->memorySource)->toBe(EstimateSource::Default) + ->and($estimate->cpuSampleCount)->toBeNull() + ->and($estimate->memorySampleCount)->toBeNull(); +}); + +it('creates from per-queue config values', function () { + $estimate = ResourceEstimate::fromConfig(0.5, 2048.0); + + expect($estimate->cpuCoresPerWorker)->toBe(0.5) + ->and($estimate->memoryMbPerWorker)->toBe(2048.0) + ->and($estimate->cpuSource)->toBe(EstimateSource::Config) + ->and($estimate->memorySource)->toBe(EstimateSource::Config) + ->and($estimate->cpuSampleCount)->toBeNull() + ->and($estimate->memorySampleCount)->toBeNull(); +}); + +it('creates from measured values with sample counts', function () { + $estimate = ResourceEstimate::measured( + cpuCoresPerWorker: 0.05, + memoryMbPerWorker: 48.0, + cpuSampleCount: 4089, + memorySampleCount: 4089, + ); + + expect($estimate->cpuCoresPerWorker)->toBe(0.05) + ->and($estimate->memoryMbPerWorker)->toBe(48.0) + ->and($estimate->cpuSource)->toBe(EstimateSource::Measured) + ->and($estimate->memorySource)->toBe(EstimateSource::Measured) + ->and($estimate->cpuSampleCount)->toBe(4089) + ->and($estimate->memorySampleCount)->toBe(4089); +}); + +it('uses default config values when not explicitly configured', function () { + config()->offsetUnset('queue-autoscale.limits.worker_cpu_core_estimate'); + config()->offsetUnset('queue-autoscale.limits.worker_memory_mb_estimate'); + + $estimate = ResourceEstimate::globalDefault(); + + expect($estimate->cpuCoresPerWorker)->toBe(0.2) + ->and($estimate->memoryMbPerWorker)->toBe(128.0); +}); diff --git a/tests/Unit/Scaling/ResourceAwareCapacityTest.php b/tests/Unit/Scaling/ResourceAwareCapacityTest.php new file mode 100644 index 0000000..b15aaae --- /dev/null +++ b/tests/Unit/Scaling/ResourceAwareCapacityTest.php @@ -0,0 +1,92 @@ +set('queue-autoscale.limits.max_cpu_percent', 100); + config()->set('queue-autoscale.limits.max_memory_percent', 100); + config()->set('queue-autoscale.limits.reserve_cpu_cores', 0); + config()->set('queue-autoscale.queues', []); + + $resolver = new ResourceEstimateResolver; + $resolver->setMeasured('redis', 'fast', 0.05, 50.0, 1000, 1000); + $resolver->setMeasured('redis', 'slow', 0.5, 2048.0, 100, 100); + + $calculator = new CapacityCalculator; + + $fastEstimate = $resolver->resolve('redis', 'fast'); + $slowEstimate = $resolver->resolve('redis', 'slow'); + + $fastCapacity = $calculator->calculateMaxWorkers(0, $fastEstimate); + $slowCapacity = $calculator->calculateMaxWorkers(0, $slowEstimate); + + // Fast queue should allow significantly more workers + if ($fastCapacity->maxWorkersByCpu > 0 && $slowCapacity->maxWorkersByCpu > 0) { + expect($fastCapacity->maxWorkersByCpu)->toBeGreaterThan($slowCapacity->maxWorkersByCpu); + } + + if ($fastCapacity->maxWorkersByMemory > 0 && $slowCapacity->maxWorkersByMemory > 0) { + expect($fastCapacity->maxWorkersByMemory)->toBeGreaterThan($slowCapacity->maxWorkersByMemory); + } + + // Verify sources + expect($fastEstimate->cpuSource)->toBe(EstimateSource::Measured) + ->and($slowEstimate->cpuSource)->toBe(EstimateSource::Measured); +}); + +it('falls back to config then default in correct order', function () { + config()->set('queue-autoscale.limits.worker_cpu_core_estimate', 0.2); + config()->set('queue-autoscale.limits.worker_memory_mb_estimate', 128); + config()->set('queue-autoscale.queues', [ + 'configured' => [ + 'resources' => [ + 'cpu_cores' => 0.4, + 'memory_mb' => 512, + ], + ], + ]); + + $resolver = new ResourceEstimateResolver; + $resolver->setMeasured('redis', 'measured', 0.1, 64.0, 500, 500); + + // Queue with measured data + $measuredEstimate = $resolver->resolve('redis', 'measured'); + expect($measuredEstimate->cpuCoresPerWorker)->toBe(0.1) + ->and($measuredEstimate->cpuSource)->toBe(EstimateSource::Measured); + + // Queue with config override + $configEstimate = $resolver->resolve('redis', 'configured'); + expect($configEstimate->cpuCoresPerWorker)->toBe(0.4) + ->and($configEstimate->cpuSource)->toBe(EstimateSource::Config); + + // Queue with no config or measured data + $defaultEstimate = $resolver->resolve('redis', 'unconfigured'); + expect($defaultEstimate->cpuCoresPerWorker)->toBe(0.2) + ->and($defaultEstimate->cpuSource)->toBe(EstimateSource::Default); +}); + +it('measured data overrides config when both exist', function () { + config()->set('queue-autoscale.queues', [ + 'heavy' => [ + 'resources' => [ + 'cpu_cores' => 0.5, + 'memory_mb' => 2048, + ], + ], + ]); + + $resolver = new ResourceEstimateResolver; + $resolver->setMeasured('redis', 'heavy', 0.3, 1024.0, 200, 200); + + $estimate = $resolver->resolve('redis', 'heavy'); + + // Measured should win over config + expect($estimate->cpuCoresPerWorker)->toBe(0.3) + ->and($estimate->memoryMbPerWorker)->toBe(1024.0) + ->and($estimate->cpuSource)->toBe(EstimateSource::Measured) + ->and($estimate->memorySource)->toBe(EstimateSource::Measured); +}); diff --git a/tests/Unit/Scaling/ResourceEstimateResolverTest.php b/tests/Unit/Scaling/ResourceEstimateResolverTest.php new file mode 100644 index 0000000..f6fe73a --- /dev/null +++ b/tests/Unit/Scaling/ResourceEstimateResolverTest.php @@ -0,0 +1,176 @@ +set('queue-autoscale.limits.worker_cpu_core_estimate', 0.2); + config()->set('queue-autoscale.limits.worker_memory_mb_estimate', 128); + config()->set('queue-autoscale.queues', []); + + $resolver = new ResourceEstimateResolver; + $estimate = $resolver->resolve('redis', 'default'); + + expect($estimate->cpuCoresPerWorker)->toBe(0.2) + ->and($estimate->memoryMbPerWorker)->toBe(128.0) + ->and($estimate->cpuSource)->toBe(EstimateSource::Default) + ->and($estimate->memorySource)->toBe(EstimateSource::Default); +}); + +it('returns per-queue config when configured, overriding global default', function () { + config()->set('queue-autoscale.limits.worker_cpu_core_estimate', 0.2); + config()->set('queue-autoscale.limits.worker_memory_mb_estimate', 128); + config()->set('queue-autoscale.queues', [ + 'slow' => [ + 'resources' => [ + 'cpu_cores' => 0.5, + 'memory_mb' => 2048, + ], + ], + ]); + + $resolver = new ResourceEstimateResolver; + $estimate = $resolver->resolve('redis', 'slow'); + + expect($estimate->cpuCoresPerWorker)->toBe(0.5) + ->and($estimate->memoryMbPerWorker)->toBe(2048.0) + ->and($estimate->cpuSource)->toBe(EstimateSource::Config) + ->and($estimate->memorySource)->toBe(EstimateSource::Config); +}); + +it('uses partial config: cpu from config, memory from global default', function () { + config()->set('queue-autoscale.limits.worker_cpu_core_estimate', 0.2); + config()->set('queue-autoscale.limits.worker_memory_mb_estimate', 128); + config()->set('queue-autoscale.queues', [ + 'mixed' => [ + 'resources' => [ + 'cpu_cores' => 0.8, + ], + ], + ]); + + $resolver = new ResourceEstimateResolver; + $estimate = $resolver->resolve('redis', 'mixed'); + + expect($estimate->cpuCoresPerWorker)->toBe(0.8) + ->and($estimate->cpuSource)->toBe(EstimateSource::Config) + ->and($estimate->memoryMbPerWorker)->toBe(128.0) + ->and($estimate->memorySource)->toBe(EstimateSource::Default); +}); + +it('returns measured data when available, overriding config', function () { + config()->set('queue-autoscale.queues', [ + 'fast' => [ + 'resources' => [ + 'cpu_cores' => 0.3, + 'memory_mb' => 256, + ], + ], + ]); + + $resolver = new ResourceEstimateResolver; + $resolver->setMeasured('redis', 'fast', 0.05, 48.0, 4089, 4089); + + $estimate = $resolver->resolve('redis', 'fast'); + + expect($estimate->cpuCoresPerWorker)->toBe(0.05) + ->and($estimate->memoryMbPerWorker)->toBe(48.0) + ->and($estimate->cpuSource)->toBe(EstimateSource::Measured) + ->and($estimate->memorySource)->toBe(EstimateSource::Measured) + ->and($estimate->cpuSampleCount)->toBe(4089) + ->and($estimate->memorySampleCount)->toBe(4089); +}); + +it('uses measured cpu but config memory when only cpu is measured', function () { + config()->set('queue-autoscale.limits.worker_memory_mb_estimate', 128); + config()->set('queue-autoscale.queues', [ + 'partial' => [ + 'resources' => [ + 'memory_mb' => 512, + ], + ], + ]); + + $resolver = new ResourceEstimateResolver; + $resolver->setMeasuredCpu('redis', 'partial', 0.1, 200); + + $estimate = $resolver->resolve('redis', 'partial'); + + expect($estimate->cpuCoresPerWorker)->toBe(0.1) + ->and($estimate->cpuSource)->toBe(EstimateSource::Measured) + ->and($estimate->cpuSampleCount)->toBe(200) + ->and($estimate->memoryMbPerWorker)->toBe(512.0) + ->and($estimate->memorySource)->toBe(EstimateSource::Config); +}); + +it('uses measured memory but global default cpu when only memory is measured', function () { + config()->set('queue-autoscale.limits.worker_cpu_core_estimate', 0.2); + config()->set('queue-autoscale.queues', []); + + $resolver = new ResourceEstimateResolver; + $resolver->setMeasuredMemory('redis', 'heavy', 2048.0, 100); + + $estimate = $resolver->resolve('redis', 'heavy'); + + expect($estimate->cpuCoresPerWorker)->toBe(0.2) + ->and($estimate->cpuSource)->toBe(EstimateSource::Default) + ->and($estimate->memoryMbPerWorker)->toBe(2048.0) + ->and($estimate->memorySource)->toBe(EstimateSource::Measured) + ->and($estimate->memorySampleCount)->toBe(100); +}); + +it('keeps queues independent — setting measured on one does not affect another', function () { + config()->set('queue-autoscale.limits.worker_cpu_core_estimate', 0.2); + config()->set('queue-autoscale.limits.worker_memory_mb_estimate', 128); + config()->set('queue-autoscale.queues', []); + + $resolver = new ResourceEstimateResolver; + $resolver->setMeasured('redis', 'fast', 0.05, 48.0, 1000, 1000); + + $fastEstimate = $resolver->resolve('redis', 'fast'); + $slowEstimate = $resolver->resolve('redis', 'slow'); + + expect($fastEstimate->cpuCoresPerWorker)->toBe(0.05) + ->and($fastEstimate->cpuSource)->toBe(EstimateSource::Measured) + ->and($slowEstimate->cpuCoresPerWorker)->toBe(0.2) + ->and($slowEstimate->cpuSource)->toBe(EstimateSource::Default); +}); + +it('resets all measured data', function () { + config()->set('queue-autoscale.limits.worker_cpu_core_estimate', 0.2); + config()->set('queue-autoscale.limits.worker_memory_mb_estimate', 128); + config()->set('queue-autoscale.queues', []); + + $resolver = new ResourceEstimateResolver; + $resolver->setMeasured('redis', 'fast', 0.05, 48.0, 1000, 1000); + $resolver->reset(); + + $estimate = $resolver->resolve('redis', 'fast'); + + expect($estimate->cpuSource)->toBe(EstimateSource::Default) + ->and($estimate->memorySource)->toBe(EstimateSource::Default); +}); + +it('clamps cpu estimate to minimum of 0.01 cores', function () { + config()->set('queue-autoscale.queues', []); + + $resolver = new ResourceEstimateResolver; + $resolver->setMeasured('redis', 'tiny', 0.001, 48.0, 100, 100); + + $estimate = $resolver->resolve('redis', 'tiny'); + + expect($estimate->cpuCoresPerWorker)->toBe(0.01); +}); + +it('clamps memory estimate to minimum of 16 MB', function () { + config()->set('queue-autoscale.queues', []); + + $resolver = new ResourceEstimateResolver; + $resolver->setMeasured('redis', 'light', 0.05, 5.0, 100, 100); + + $estimate = $resolver->resolve('redis', 'light'); + + expect($estimate->memoryMbPerWorker)->toBe(16.0); +}); diff --git a/tests/Unit/ScalingEngineEvaluateDemandTest.php b/tests/Unit/ScalingEngineEvaluateDemandTest.php index 779a001..3a86ac3 100644 --- a/tests/Unit/ScalingEngineEvaluateDemandTest.php +++ b/tests/Unit/ScalingEngineEvaluateDemandTest.php @@ -10,6 +10,7 @@ use Cbox\LaravelQueueAutoscale\Scaling\Calculators\BacklogDrainCalculator; use Cbox\LaravelQueueAutoscale\Scaling\Calculators\CapacityCalculator; use Cbox\LaravelQueueAutoscale\Scaling\Calculators\LittlesLawCalculator; +use Cbox\LaravelQueueAutoscale\Scaling\ResourceEstimateResolver; use Cbox\LaravelQueueAutoscale\Scaling\ScalingEngine; use Cbox\LaravelQueueAutoscale\Scaling\Strategies\HybridStrategy; @@ -45,7 +46,7 @@ public function recentSamples(string $connection, string $queue, int $windowSeco percentileCalc: new SortBasedPercentileCalculator, ); $this->capacity = new CapacityCalculator; - $this->engine = new ScalingEngine($this->strategy, $this->capacity); + $this->engine = new ScalingEngine($this->strategy, $this->capacity, new ResourceEstimateResolver); }); it('evaluateDemand clamps at config max workers', function () { diff --git a/tests/Unit/ScalingEngineTest.php b/tests/Unit/ScalingEngineTest.php index 60bfeec..ce1d53a 100644 --- a/tests/Unit/ScalingEngineTest.php +++ b/tests/Unit/ScalingEngineTest.php @@ -10,6 +10,7 @@ use Cbox\LaravelQueueAutoscale\Scaling\Calculators\BacklogDrainCalculator; use Cbox\LaravelQueueAutoscale\Scaling\Calculators\CapacityCalculator; use Cbox\LaravelQueueAutoscale\Scaling\Calculators\LittlesLawCalculator; +use Cbox\LaravelQueueAutoscale\Scaling\ResourceEstimateResolver; use Cbox\LaravelQueueAutoscale\Scaling\ScalingDecision; use Cbox\LaravelQueueAutoscale\Scaling\ScalingEngine; use Cbox\LaravelQueueAutoscale\Scaling\Strategies\HybridStrategy; @@ -46,7 +47,8 @@ public function recentSamples(string $connection, string $queue, int $windowSeco percentileCalc: new SortBasedPercentileCalculator, ); $this->capacity = new CapacityCalculator; - $this->engine = new ScalingEngine($this->strategy, $this->capacity); + $this->resolver = new ResourceEstimateResolver; + $this->engine = new ScalingEngine($this->strategy, $this->capacity, $this->resolver); $this->config = makeQueueConfig();