diff --git a/src/Manager/AutoscaleManager.php b/src/Manager/AutoscaleManager.php index 38c1eea..1f9a655 100644 --- a/src/Manager/AutoscaleManager.php +++ b/src/Manager/AutoscaleManager.php @@ -477,6 +477,7 @@ private function evaluateAndPublishClusterRecommendations(): void 'name' => $meta['name'], 'driver' => $meta['driver'], 'current_workers' => $currentWorkers, + 'demand' => $demands[$workloadKey], 'target_workers' => $targetWorkers, 'worker_min' => $meta['config']->workers->min, 'worker_max' => $meta['config']->workers->max, @@ -891,7 +892,7 @@ private function buildClusterSummary(array $activeManagers, array $workloads, ar $currentHosts = count($activeManagers); $totalWorkerCapacity = array_sum(array_map(static fn (ClusterManagerState $state): int => $state->maxWorkers, $activeManagers)); - $requiredWorkers = array_sum(array_map(static fn (array $workload): int => (int) $workload['target_workers'], $workloads)); + $requiredWorkers = array_sum(array_map(static fn (array $workload): int => (int) $workload['demand'], $workloads)); $totalWorkers = array_sum(array_map(static fn (ClusterManagerState $state): int => $state->totalWorkers, $activeManagers)); $recommendedHosts = $this->recommendedHostCount($activeManagers, $requiredWorkers); $signal = $this->clusterScaleSignal($currentHosts, $recommendedHosts, $requiredWorkers, $totalWorkerCapacity, $totalWorkers, $workloads); diff --git a/tests/Unit/Cluster/ClusterScalingDecisionsTest.php b/tests/Unit/Cluster/ClusterScalingDecisionsTest.php index a92723f..2071cc3 100644 --- a/tests/Unit/Cluster/ClusterScalingDecisionsTest.php +++ b/tests/Unit/Cluster/ClusterScalingDecisionsTest.php @@ -62,6 +62,7 @@ function invokeBuildClusterSummary(array $managers, array $workloads, array $sca 'name' => 'default', 'driver' => 'redis', 'current_workers' => 1, + 'demand' => 3, 'target_workers' => 3, 'worker_min' => 1, 'worker_max' => 10, @@ -96,6 +97,7 @@ function invokeBuildClusterSummary(array $managers, array $workloads, array $sca 'name' => 'fast', 'driver' => 'redis', 'current_workers' => 1, + 'demand' => 6, 'target_workers' => 6, 'worker_min' => 1, 'worker_max' => 10, @@ -115,6 +117,7 @@ function invokeBuildClusterSummary(array $managers, array $workloads, array $sca 'name' => 'slow', 'driver' => 'redis', 'current_workers' => 1, + 'demand' => 3, 'target_workers' => 3, 'worker_min' => 1, 'worker_max' => 10, @@ -177,6 +180,7 @@ function invokeBuildClusterSummary(array $managers, array $workloads, array $sca 'name' => 'stable', 'driver' => 'redis', 'current_workers' => 5, + 'demand' => 5, 'target_workers' => 5, 'worker_min' => 1, 'worker_max' => 10, @@ -210,6 +214,7 @@ function invokeBuildClusterSummary(array $managers, array $workloads, array $sca 'name' => 'draining', 'driver' => 'redis', 'current_workers' => 8, + 'demand' => 2, 'target_workers' => 2, 'worker_min' => 1, 'worker_max' => 10, @@ -260,6 +265,7 @@ function invokeBuildClusterSummary(array $managers, array $workloads, array $sca 'name' => 'default', 'driver' => 'redis', 'current_workers' => 5, + 'demand' => 5, 'target_workers' => 5, 'worker_min' => 1, 'worker_max' => 10, @@ -324,3 +330,206 @@ function invokeBuildClusterSummary(array $managers, array $workloads, array $sca ->and($summary['scaling_decisions'][2]['from'])->toBe(8) ->and($summary['scaling_decisions'][2]['to'])->toBe(5); }); + +it('produces scale_up signal when unclamped demand exceeds cluster capacity', function () { + config()->set('queue-autoscale.cluster.enabled', true); + config()->set('queue-autoscale.cluster.app_id', 'test-cluster'); + + // 2 hosts, 5 max workers each = 10 total capacity + $managers = [ + makeSummaryManagerState('host-a', maxWorkers: 5, totalWorkers: 5), + makeSummaryManagerState('host-b', maxWorkers: 5, totalWorkers: 5), + ]; + + // 4 queues with demand summing to 30, but targets clamped to fit capacity (10) + $workloads = [ + [ + 'type' => 'queue', + 'connection' => 'redis', + 'name' => 'fast', + 'driver' => 'redis', + 'current_workers' => 4, + 'demand' => 15, + 'target_workers' => 4, + 'worker_min' => 1, + 'worker_max' => 20, + 'sla_target_seconds' => 10, + 'pending' => 240, + 'oldest_job_age' => 12, + 'oldest_job_age_status' => 'warning', + 'throughput_per_minute' => 300.0, + 'active_workers' => 4, + 'utilization_percent' => 95.0, + 'member_queues' => ['fast'], + 'action' => 0, + ], + [ + 'type' => 'queue', + 'connection' => 'redis', + 'name' => 'priority', + 'driver' => 'redis', + 'current_workers' => 3, + 'demand' => 8, + 'target_workers' => 3, + 'worker_min' => 1, + 'worker_max' => 15, + 'sla_target_seconds' => 15, + 'pending' => 80, + 'oldest_job_age' => 8, + 'oldest_job_age_status' => 'normal', + 'throughput_per_minute' => 150.0, + 'active_workers' => 3, + 'utilization_percent' => 90.0, + 'member_queues' => ['priority'], + 'action' => 0, + ], + [ + 'type' => 'queue', + 'connection' => 'redis', + 'name' => 'slow', + 'driver' => 'redis', + 'current_workers' => 2, + 'demand' => 5, + 'target_workers' => 2, + 'worker_min' => 1, + 'worker_max' => 10, + 'sla_target_seconds' => 60, + 'pending' => 40, + 'oldest_job_age' => 15, + 'oldest_job_age_status' => 'normal', + 'throughput_per_minute' => 50.0, + 'active_workers' => 2, + 'utilization_percent' => 85.0, + 'member_queues' => ['slow'], + 'action' => 0, + ], + [ + 'type' => 'queue', + 'connection' => 'redis', + 'name' => 'email', + 'driver' => 'redis', + 'current_workers' => 1, + 'demand' => 2, + 'target_workers' => 1, + 'worker_min' => 1, + 'worker_max' => 5, + 'sla_target_seconds' => 120, + 'pending' => 10, + 'oldest_job_age' => 3, + 'oldest_job_age_status' => 'normal', + 'throughput_per_minute' => 20.0, + 'active_workers' => 1, + 'utilization_percent' => 60.0, + 'member_queues' => ['email'], + 'action' => 0, + ], + ]; + + $summary = invokeBuildClusterSummary($managers, $workloads); + + // required_workers should reflect unclamped demand (30), not clamped targets (10) + expect($summary['required_workers'])->toBe(30) + ->and($summary['total_worker_capacity'])->toBe(10) + ->and($summary['scale_signal']['action'])->toBe('scale_up') + ->and($summary['scale_signal']['recommended_hosts'])->toBeGreaterThan(2); +}); + +it('produces hold signal when demand fits within cluster capacity', function () { + config()->set('queue-autoscale.cluster.enabled', true); + config()->set('queue-autoscale.cluster.app_id', 'test-cluster'); + + // 2 hosts, 5 max workers each = 10 total capacity + $managers = [ + makeSummaryManagerState('host-a', maxWorkers: 5, totalWorkers: 4), + makeSummaryManagerState('host-b', maxWorkers: 5, totalWorkers: 4), + ]; + + // demand sums to 8, fits within capacity of 10 + $workloads = [ + [ + 'type' => 'queue', + 'connection' => 'redis', + 'name' => 'fast', + 'driver' => 'redis', + 'current_workers' => 4, + 'demand' => 5, + 'target_workers' => 5, + 'worker_min' => 1, + 'worker_max' => 10, + 'sla_target_seconds' => 10, + 'pending' => 0, + 'oldest_job_age' => 2, + 'oldest_job_age_status' => 'normal', + 'throughput_per_minute' => 100.0, + 'active_workers' => 4, + 'utilization_percent' => 50.0, + 'member_queues' => ['fast'], + 'action' => 1, + ], + [ + 'type' => 'queue', + 'connection' => 'redis', + 'name' => 'slow', + 'driver' => 'redis', + 'current_workers' => 3, + 'demand' => 3, + 'target_workers' => 3, + 'worker_min' => 1, + 'worker_max' => 10, + 'sla_target_seconds' => 60, + 'pending' => 0, + 'oldest_job_age' => 1, + 'oldest_job_age_status' => 'normal', + 'throughput_per_minute' => 50.0, + 'active_workers' => 3, + 'utilization_percent' => 40.0, + 'member_queues' => ['slow'], + 'action' => 0, + ], + ]; + + $summary = invokeBuildClusterSummary($managers, $workloads); + + expect($summary['required_workers'])->toBe(8) + ->and($summary['scale_signal']['action'])->not->toBe('scale_up') + ->and($summary['scale_signal']['recommended_hosts'])->toBe(2); +}); + +it('surfaces both demand and target_workers in workload entries', function () { + config()->set('queue-autoscale.cluster.enabled', true); + config()->set('queue-autoscale.cluster.app_id', 'test-cluster'); + + $managers = [makeSummaryManagerState('host-a', maxWorkers: 10, totalWorkers: 8)]; + + $workloads = [ + [ + 'type' => 'queue', + 'connection' => 'redis', + 'name' => 'fast', + 'driver' => 'redis', + 'current_workers' => 8, + 'demand' => 25, + 'target_workers' => 8, + 'worker_min' => 1, + 'worker_max' => 30, + 'sla_target_seconds' => 10, + 'pending' => 200, + 'oldest_job_age' => 15, + 'oldest_job_age_status' => 'warning', + 'throughput_per_minute' => 400.0, + 'active_workers' => 8, + 'utilization_percent' => 95.0, + 'member_queues' => ['fast'], + 'action' => 0, + ], + ]; + + $summary = invokeBuildClusterSummary($managers, $workloads); + + $workload = $summary['workloads'][0]; + expect($workload)->toHaveKey('demand') + ->and($workload)->toHaveKey('target_workers') + ->and($workload['demand'])->toBe(25) + ->and($workload['target_workers'])->toBe(8) + ->and($workload['demand'])->toBeGreaterThan($workload['target_workers']); +});