Scaling Policies
Scaling Policies
Complete guide to implementing and using scaling policies in Laravel Queue Autoscale.
Table of Contents
- Overview
- Policy Contract
- Implementation Steps
- Policy Examples
- Testing Policies
- Best Practices
- Common Use Cases
Overview
Scaling policies add cross-cutting concerns to the autoscaling process. They execute before and after scaling decisions, allowing you to:
- Send notifications (Slack, email, PagerDuty)
- Log metrics and decisions
- Enforce resource constraints
- Implement custom validation
- Track scaling history
- Integrate with external systems
When to Use Policies
Use policies when you need to:
- React to scaling events (notifications, alerts)
- Enforce constraints (budget limits, resource caps)
- Collect data (metrics, analytics, audit trails)
- Integrate systems (monitoring, incident management)
- Validate decisions (compliance, safety checks)
Policy vs Strategy
Strategies calculate how many workers are needed. Policies add behavior around scaling decisions.
┌─────────────────────────────────────┐
│ Scaling Decision Flow │
├─────────────────────────────────────┤
│ │
│ 1. Policies: beforeScaling() │ ← Validate, log, prepare
│ │
│ 2. Strategy: calculateWorkers() │ ← Core calculation
│ │
│ 3. Policies: afterScaling() │ ← Notify, record, cleanup
│ │
└─────────────────────────────────────┘
Policy Contract
All policies must implement ScalingPolicyContract:
<?php
namespace Cbox\LaravelQueueAutoscale\Contracts;
use Cbox\LaravelQueueAutoscale\Configuration\QueueConfiguration;
use Cbox\LaravelQueueAutoscale\Scaling\ScalingDecision;
interface ScalingPolicyContract
{
/**
* Execute before scaling decision is made
*
* @param object $metrics Queue metrics
* @param QueueConfiguration $config Queue configuration
* @param int $currentWorkers Current worker count
* @return void
*/
public function beforeScaling(object $metrics, QueueConfiguration $config, int $currentWorkers): void;
/**
* Execute after scaling decision is made
*
* @param ScalingDecision $decision The scaling decision
* @param QueueConfiguration $config Queue configuration
* @param int $currentWorkers Current worker count
* @return void
*/
public function afterScaling(ScalingDecision $decision, QueueConfiguration $config, int $currentWorkers): void;
}
Method Responsibilities
beforeScaling()
Called before the strategy calculates workers.
Use for:
- Logging decision start
- Validating preconditions
- Preparing external systems
- Recording metrics state
afterScaling()
Called after the strategy calculates workers.
Use for:
- Sending notifications
- Recording decisions
- Updating external systems
- Logging outcomes
Implementation Steps
Step 1: Create Policy Class
<?php
namespace App\Autoscale\Policies;
use Cbox\LaravelQueueAutoscale\Configuration\QueueConfiguration;
use Cbox\LaravelQueueAutoscale\Contracts\ScalingPolicyContract;
use Cbox\LaravelQueueAutoscale\Scaling\ScalingDecision;
class CustomPolicy implements ScalingPolicyContract
{
public function beforeScaling(object $metrics, QueueConfiguration $config, int $currentWorkers): void
{
// Your before logic here
}
public function afterScaling(ScalingDecision $decision, QueueConfiguration $config, int $currentWorkers): void
{
// Your after logic here
}
}
Step 2: Register Policy
Add to config/queue-autoscale.php:
'policies' => [
\Cbox\LaravelQueueAutoscale\Scaling\Policies\ResourceConstraintPolicy::class,
\Cbox\LaravelQueueAutoscale\Scaling\Policies\CooldownEnforcementPolicy::class,
// Your custom policies
\App\Autoscale\Policies\CustomPolicy::class,
],
Step 3: Test Policy
use App\Autoscale\Policies\CustomPolicy;
use Cbox\LaravelQueueAutoscale\Configuration\QueueConfiguration;
use Cbox\LaravelQueueAutoscale\Scaling\ScalingDecision;
it('executes policy hooks', function () {
$policy = new CustomPolicy();
$metrics = (object) ['processingRate' => 10.0];
$config = new QueueConfiguration(
connection: 'redis',
queue: 'default',
maxPickupTimeSeconds: 60,
minWorkers: 1,
maxWorkers: 10,
);
// Test before hook
$policy->beforeScaling($metrics, $config, 5);
// Test after hook
$decision = new ScalingDecision(
targetWorkers: 10,
reason: 'Test scaling',
confidence: 0.9,
predictedPickupTime: 5.0
);
$policy->afterScaling($decision, $config, 5);
// Assert your expectations
});
Policy Examples
Example 1: Slack Notification Policy
Send scaling notifications to Slack:
<?php
namespace App\Autoscale\Policies;
use Illuminate\Support\Facades\Http;
use Cbox\LaravelQueueAutoscale\Configuration\QueueConfiguration;
use Cbox\LaravelQueueAutoscale\Contracts\ScalingPolicyContract;
use Cbox\LaravelQueueAutoscale\Scaling\ScalingDecision;
class SlackNotificationPolicy implements ScalingPolicyContract
{
public function __construct(
private readonly string $webhookUrl,
private readonly int $minWorkerChange = 5 // Only notify for significant changes
) {}
public function beforeScaling(object $metrics, QueueConfiguration $config, int $currentWorkers): void
{
// No action needed before scaling
}
public function afterScaling(ScalingDecision $decision, QueueConfiguration $config, int $currentWorkers): void
{
$workerChange = abs($decision->targetWorkers - $currentWorkers);
// Only notify for significant changes
if ($workerChange < $this->minWorkerChange) {
return;
}
$direction = $decision->targetWorkers > $currentWorkers ? '⬆️ SCALE UP' : '⬇️ SCALE DOWN';
$color = $decision->targetWorkers > $currentWorkers ? '#36a64f' : '#ff9900';
$message = [
'attachments' => [
[
'color' => $color,
'title' => "{$direction}: {$config->queue} queue",
'fields' => [
[
'title' => 'Worker Change',
'value' => "{$currentWorkers} → {$decision->targetWorkers}",
'short' => true,
],
[
'title' => 'Pending Jobs',
'value' => $metrics->depth->pending ?? 'N/A',
'short' => true,
],
[
'title' => 'Reason',
'value' => $decision->reason,
'short' => false,
],
[
'title' => 'Predicted Pickup Time',
'value' => $decision->predictedPickupTime
? sprintf('%.1fs', $decision->predictedPickupTime)
: 'N/A',
'short' => true,
],
[
'title' => 'Confidence',
'value' => sprintf('%.1f%%', $decision->confidence * 100),
'short' => true,
],
],
'footer' => 'Laravel Queue Autoscale',
'ts' => time(),
],
],
];
Http::post($this->webhookUrl, $message);
}
}
Usage:
'policies' => [
new \App\Autoscale\Policies\SlackNotificationPolicy(
webhookUrl: config('services.slack.autoscale_webhook'),
minWorkerChange: 5
),
],
Example 2: Metrics Logging Policy
Log detailed metrics for analysis:
<?php
namespace App\Autoscale\Policies;
use Illuminate\Support\Facades\DB;
use Cbox\LaravelQueueAutoscale\Configuration\QueueConfiguration;
use Cbox\LaravelQueueAutoscale\Contracts\ScalingPolicyContract;
use Cbox\LaravelQueueAutoscale\Scaling\ScalingDecision;
class MetricsLoggingPolicy implements ScalingPolicyContract
{
public function beforeScaling(object $metrics, QueueConfiguration $config, int $currentWorkers): void
{
// Log pre-scaling metrics
DB::table('autoscale_metrics')->insert([
'connection' => $config->connection,
'queue' => $config->queue,
'event_type' => 'before_scaling',
'current_workers' => $currentWorkers,
'pending_jobs' => $metrics->depth->pending ?? null,
'oldest_job_age' => $metrics->depth->oldestJobAgeSeconds ?? null,
'processing_rate' => $metrics->processingRate ?? null,
'trend_direction' => $metrics->trend->direction ?? null,
'trend_forecast' => $metrics->trend->forecast ?? null,
'cpu_percent' => $metrics->resources->cpuPercent ?? null,
'memory_percent' => $metrics->resources->memoryPercent ?? null,
'recorded_at' => now(),
]);
}
public function afterScaling(ScalingDecision $decision, QueueConfiguration $config, int $currentWorkers): void
{
// Log scaling decision
DB::table('autoscale_decisions')->insert([
'connection' => $config->connection,
'queue' => $config->queue,
'current_workers' => $currentWorkers,
'target_workers' => $decision->targetWorkers,
'worker_change' => $decision->targetWorkers - $currentWorkers,
'reason' => $decision->reason,
'confidence' => $decision->confidence,
'predicted_pickup_time' => $decision->predictedPickupTime,
'max_pickup_time_sla' => $config->maxPickupTimeSeconds,
'decision_at' => now(),
]);
}
}
Create migration:
Schema::create('autoscale_metrics', function (Blueprint $table) {
$table->id();
$table->string('connection');
$table->string('queue');
$table->string('event_type');
$table->integer('current_workers');
$table->integer('pending_jobs')->nullable();
$table->float('oldest_job_age')->nullable();
$table->float('processing_rate')->nullable();
$table->string('trend_direction')->nullable();
$table->float('trend_forecast')->nullable();
$table->float('cpu_percent')->nullable();
$table->float('memory_percent')->nullable();
$table->timestamp('recorded_at');
$table->index(['connection', 'queue', 'recorded_at']);
});
Schema::create('autoscale_decisions', function (Blueprint $table) {
$table->id();
$table->string('connection');
$table->string('queue');
$table->integer('current_workers');
$table->integer('target_workers');
$table->integer('worker_change');
$table->text('reason');
$table->float('confidence');
$table->float('predicted_pickup_time')->nullable();
$table->integer('max_pickup_time_sla');
$table->timestamp('decision_at');
$table->index(['connection', 'queue', 'decision_at']);
});
Example 3: Budget Enforcement Policy
Prevent cost overruns:
<?php
namespace App\Autoscale\Policies;
use Illuminate\Support\Facades\Cache;
use Cbox\LaravelQueueAutoscale\Configuration\QueueConfiguration;
use Cbox\LaravelQueueAutoscale\Contracts\ScalingPolicyContract;
use Cbox\LaravelQueueAutoscale\Scaling\ScalingDecision;
class BudgetEnforcementPolicy implements ScalingPolicyContract
{
public function __construct(
private readonly float $hourlyBudget = 100.00,
private readonly float $workerCostPerHour = 0.50
) {}
public function beforeScaling(object $metrics, QueueConfiguration $config, int $currentWorkers): void
{
// No validation needed before
}
public function afterScaling(ScalingDecision $decision, QueueConfiguration $config, int $currentWorkers): void
{
$currentHour = now()->format('Y-m-d-H');
$cacheKey = "autoscale:budget:{$currentHour}";
// Calculate cost for this hour
$currentSpend = Cache::get($cacheKey, 0.0);
$projectedCost = $decision->targetWorkers * $this->workerCostPerHour;
if ($currentSpend + $projectedCost > $this->hourlyBudget) {
// Reduce workers to fit budget
$maxAffordableWorkers = (int) floor(($this->hourlyBudget - $currentSpend) / $this->workerCostPerHour);
// Update decision (reflection hack for readonly properties)
$reflection = new \ReflectionProperty($decision, 'targetWorkers');
$reflection->setAccessible(true);
$reflection->setValue($decision, max($config->minWorkers, $maxAffordableWorkers));
$reflection = new \ReflectionProperty($decision, 'reason');
$reflection->setAccessible(true);
$reflection->setValue(
$decision,
"Budget constraint: reduced to {$maxAffordableWorkers} workers (original: {$decision->reason})"
);
// Log budget event
logger()->warning('Autoscale budget constraint applied', [
'queue' => $config->queue,
'original_workers' => $decision->targetWorkers,
'budget_workers' => $maxAffordableWorkers,
'current_spend' => $currentSpend,
'budget' => $this->hourlyBudget,
]);
}
// Track spending
Cache::put($cacheKey, $currentSpend + $projectedCost, now()->addHours(2));
}
}
Example 4: PagerDuty Integration
Alert on-call for critical scaling events:
<?php
namespace App\Autoscale\Policies;
use Illuminate\Support\Facades\Http;
use Cbox\LaravelQueueAutoscale\Configuration\QueueConfiguration;
use Cbox\LaravelQueueAutoscale\Contracts\ScalingPolicyContract;
use Cbox\LaravelQueueAutoscale\Scaling\ScalingDecision;
class PagerDutyAlertPolicy implements ScalingPolicyContract
{
public function __construct(
private readonly string $integrationKey,
private readonly float $slaBreachThreshold = 0.9 // Alert at 90% of SLA
) {}
public function beforeScaling(object $metrics, QueueConfiguration $config, int $currentWorkers): void
{
$oldestJobAge = $metrics->depth->oldestJobAgeSeconds ?? 0;
$slaLimit = $config->maxPickupTimeSeconds;
// Check for imminent SLA breach
if ($oldestJobAge > ($slaLimit * $this->slaBreachThreshold)) {
$this->triggerAlert(
severity: 'warning',
summary: "Queue SLA breach imminent: {$config->queue}",
details: [
'queue' => $config->queue,
'oldest_job_age' => $oldestJobAge,
'sla_limit' => $slaLimit,
'pending_jobs' => $metrics->depth->pending ?? 0,
'current_workers' => $currentWorkers,
]
);
}
}
public function afterScaling(ScalingDecision $decision, QueueConfiguration $config, int $currentWorkers): void
{
// Alert if we hit max workers (capacity limit)
if ($decision->targetWorkers >= $config->maxWorkers) {
$this->triggerAlert(
severity: 'error',
summary: "Queue at maximum capacity: {$config->queue}",
details: [
'queue' => $config->queue,
'max_workers' => $config->maxWorkers,
'current_workers' => $currentWorkers,
'reason' => $decision->reason,
]
);
}
}
private function triggerAlert(string $severity, string $summary, array $details): void
{
Http::post('https://events.pagerduty.com/v2/enqueue', [
'routing_key' => $this->integrationKey,
'event_action' => 'trigger',
'payload' => [
'summary' => $summary,
'severity' => $severity,
'source' => 'laravel-queue-autoscale',
'custom_details' => $details,
],
]);
}
}
Example 5: Cooldown Tracking Policy
Track and enforce cooldown periods:
<?php
namespace App\Autoscale\Policies;
use Illuminate\Support\Facades\Cache;
use Cbox\LaravelQueueAutoscale\Configuration\QueueConfiguration;
use Cbox\LaravelQueueAutoscale\Contracts\ScalingPolicyContract;
use Cbox\LaravelQueueAutoscale\Scaling\ScalingDecision;
class CooldownTrackingPolicy implements ScalingPolicyContract
{
public function beforeScaling(object $metrics, QueueConfiguration $config, int $currentWorkers): void
{
$cacheKey = "autoscale:cooldown:{$config->connection}:{$config->queue}";
$lastScaleTime = Cache::get($cacheKey);
if ($lastScaleTime && now()->timestamp - $lastScaleTime < $config->scaleCooldownSeconds) {
$remainingCooldown = $config->scaleCooldownSeconds - (now()->timestamp - $lastScaleTime);
logger()->info('Scaling suppressed by cooldown', [
'queue' => $config->queue,
'remaining_seconds' => $remainingCooldown,
]);
}
}
public function afterScaling(ScalingDecision $decision, QueueConfiguration $config, int $currentWorkers): void
{
// Only update cooldown if workers actually changed
if ($decision->targetWorkers !== $currentWorkers) {
$cacheKey = "autoscale:cooldown:{$config->connection}:{$config->queue}";
Cache::put($cacheKey, now()->timestamp, $config->scaleCooldownSeconds);
logger()->info('Cooldown period started', [
'queue' => $config->queue,
'duration_seconds' => $config->scaleCooldownSeconds,
'worker_change' => $decision->targetWorkers - $currentWorkers,
]);
}
}
}
Testing Policies
Unit Tests
Test policy behavior in isolation:
use App\Autoscale\Policies\SlackNotificationPolicy;
use Illuminate\Support\Facades\Http;
use Cbox\LaravelQueueAutoscale\Configuration\QueueConfiguration;
use Cbox\LaravelQueueAutoscale\Scaling\ScalingDecision;
describe('SlackNotificationPolicy', function () {
beforeEach(function () {
Http::fake();
$this->policy = new SlackNotificationPolicy(
webhookUrl: 'https://hooks.slack.com/test',
minWorkerChange: 5
);
$this->config = new QueueConfiguration(
connection: 'redis',
queue: 'default',
maxPickupTimeSeconds: 60,
minWorkers: 1,
maxWorkers: 20,
);
});
it('sends notification for significant worker increase', function () {
$decision = new ScalingDecision(
targetWorkers: 15,
reason: 'High load detected',
confidence: 0.9,
predictedPickupTime: 30.0
);
$metrics = (object) [
'depth' => (object) ['pending' => 100],
];
$this->policy->afterScaling($decision, $this->config, 5);
Http::assertSent(function ($request) {
return $request->url() === 'https://hooks.slack.com/test'
&& str_contains($request['attachments'][0]['title'], 'SCALE UP');
});
});
it('does not send notification for small changes', function () {
$decision = new ScalingDecision(
targetWorkers: 7,
reason: 'Minor adjustment',
confidence: 0.8,
predictedPickupTime: 45.0
);
$metrics = (object) ['depth' => (object) ['pending' => 10]];
$this->policy->afterScaling($decision, $this->config, 5);
Http::assertNothingSent();
});
});
Integration Tests
Test policy interaction with scaling engine:
use App\Autoscale\Policies\MetricsLoggingPolicy;
use Illuminate\Support\Facades\DB;
use Cbox\LaravelQueueAutoscale\Scaling\ScalingEngine;
it('logs metrics during scaling evaluation', function () {
DB::shouldReceive('table->insert')->twice(); // before + after
$policy = new MetricsLoggingPolicy();
// Register policy with engine
$engine = app(ScalingEngine::class);
// ... configure engine with policy
$metrics = (object) [
'processingRate' => 10.0,
'activeWorkerCount' => 5,
'depth' => (object) ['pending' => 100, 'oldestJobAgeSeconds' => 5],
];
$config = new QueueConfiguration(
connection: 'redis',
queue: 'default',
maxPickupTimeSeconds: 60,
minWorkers: 1,
maxWorkers: 20,
);
$engine->evaluate($metrics, $config, 5);
// Both before and after should have been logged
});
Best Practices
1. Keep Policies Focused
Each policy should have a single responsibility:
// ✅ Good: Single responsibility
class SlackNotificationPolicy implements ScalingPolicyContract { }
class MetricsLoggingPolicy implements ScalingPolicyContract { }
// ❌ Bad: Multiple responsibilities
class NotificationAndLoggingPolicy implements ScalingPolicyContract { }
2. Handle Failures Gracefully
Don't let policy failures break scaling:
public function afterScaling(ScalingDecision $decision, QueueConfiguration $config, int $currentWorkers): void
{
try {
Http::timeout(5)->post($this->webhookUrl, $this->buildMessage($decision));
} catch (\Exception $e) {
// Log but don't throw - don't break scaling
logger()->error('Slack notification failed', [
'error' => $e->getMessage(),
'queue' => $config->queue,
]);
}
}
3. Use Dependency Injection
Make policies testable:
public function __construct(
private readonly HttpClient $http,
private readonly Logger $logger
) {}
// Test with mocks
$policy = new SlackNotificationPolicy(
http: $mockHttp,
logger: $mockLogger
);
4. Respect Performance
Policies execute on every evaluation - keep them fast:
// ✅ Good: Fast, async
Http::async()->post($url, $data);
// ❌ Bad: Slow, synchronous
sleep(5);
Http::retry(3, 10000)->post($url, $data);
5. Make Policies Configurable
Use constructor parameters:
public function __construct(
private readonly int $minWorkerChange = 5,
private readonly array $notifyChannels = ['slack', 'email'],
private readonly bool $enableDebugLogging = false
) {}
Common Use Cases
Use Case 1: Multi-Channel Notifications
Notify different channels based on severity:
class MultiChannelNotificationPolicy implements ScalingPolicyContract
{
public function afterScaling(ScalingDecision $decision, QueueConfiguration $config, int $currentWorkers): void
{
$workerChange = abs($decision->targetWorkers - $currentWorkers);
if ($workerChange >= 20) {
// Critical: PagerDuty + Slack + Email
$this->pagerDuty->alert(...);
$this->slack->notify(...);
$this->email->send(...);
} elseif ($workerChange >= 10) {
// Warning: Slack + Email
$this->slack->notify(...);
$this->email->send(...);
} elseif ($workerChange >= 5) {
// Info: Slack only
$this->slack->notify(...);
}
}
}
Use Case 2: Audit Trail
Maintain compliance audit trail:
class AuditTrailPolicy implements ScalingPolicyContract
{
public function afterScaling(ScalingDecision $decision, QueueConfiguration $config, int $currentWorkers): void
{
DB::table('autoscale_audit_log')->insert([
'user_id' => auth()->id() ?? null,
'action' => 'scaling_decision',
'queue' => $config->queue,
'before_workers' => $currentWorkers,
'after_workers' => $decision->targetWorkers,
'reason' => $decision->reason,
'decision_data' => json_encode($decision),
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent(),
'created_at' => now(),
]);
}
}
Use Case 3: External Metrics Integration
Send to Datadog, Prometheus, etc:
class DatadogMetricsPolicy implements ScalingPolicyContract
{
public function afterScaling(ScalingDecision $decision, QueueConfiguration $config, int $currentWorkers): void
{
$this->datadog->gauge('queue.autoscale.workers', $decision->targetWorkers, [
'queue' => $config->queue,
'connection' => $config->connection,
]);
$this->datadog->gauge('queue.autoscale.predicted_pickup_time', $decision->predictedPickupTime ?? 0, [
'queue' => $config->queue,
]);
$this->datadog->increment('queue.autoscale.decisions', 1, [
'queue' => $config->queue,
'direction' => $decision->targetWorkers > $currentWorkers ? 'up' : 'down',
]);
}
}
See Also
- Custom Strategies - Implementing custom strategies
- Event Handling - Using Laravel events
- Monitoring - Monitoring and observability
- API Reference - Complete API documentation