Policy Execution Internals
Policy Execution Internals
This page documents the machinery around Cbox\LaravelQueueAutoscale\Contracts\ScalingPolicy:
where policies run in the evaluation pipeline, how they are resolved and chained, what happens when
one throws, and the exact arithmetic inside the four shipped policies.
For the introduction — what a policy is, when to reach for one, and how to write your first one — start with Scaling Policies.
The contract
namespace Cbox\LaravelQueueAutoscale\Contracts;
use Cbox\LaravelQueueAutoscale\Scaling\ScalingDecision;
interface ScalingPolicy
{
public function beforeScaling(ScalingDecision $decision): ?ScalingDecision;
public function afterScaling(ScalingDecision $decision): void;
}
Both hooks receive a ScalingDecision and nothing else. There is no metrics object and no
QueueConfiguration in the signature — everything a policy can react to must be reachable from the
decision itself:
| Property | Type | Notes |
|---|---|---|
connection |
string |
|
queue |
string |
Group decisions carry the group name here |
currentWorkers |
int |
|
targetWorkers |
int |
Already clamped by capacity, config bounds and the fuse |
reason |
string |
Composed by the engine from the strategy and fuse |
predictedPickupTime |
?float |
ScalingStrategyContract::getLastPrediction() |
slaTarget |
int |
sla.target_seconds, default 30 |
capacity |
?CapacityCalculationResult |
|
spawnCompensation |
?SpawnCompensationConfiguration |
Helper methods: shouldScaleUp(), shouldScaleDown(), shouldHold(), workersToAdd(),
workersToRemove(), action() ('scale_up' | 'scale_down' | 'hold') and isSlaBreachRisk()
(predictedPickupTime > slaTarget).
ScalingDecision is readonly. A policy never mutates a decision — it returns a new one, or
null to leave the incoming decision untouched.
Where policies run
Policies run after the strategy and the engine, on a finished ScalingDecision. They do not
feed the calculation; they adjust its result.
metrics (QueueMetricsData)
└─ ScalingStrategyContract::calculateTargetWorkers() ← the demand calculation
└─ ScalingEngine::evaluate()
├─ min(target, this queue's share of system capacity)
├─ clamp to [workers.min, workers.max]
└─ apply failure-fuse ceiling
└─ ScalingDecision
├─ PolicyExecutor::beforeScaling() ← policies, chained
├─ spawn / terminate workers
├─ PolicyExecutor::afterScaling()
└─ ScalingDecisionMade / SlaBreachPredicted events
Two consequences worth internalising:
- The anti-flapping cooldown runs before the policies.
AutoscaleManager::evaluateQueue()returns early when a decision would reverse a recent scale-up into a scale-down insidequeue-autoscale.scaling.cooldown_seconds, so on a suppressed cycle neither hook is called at all. A scale-up is never suppressed, so it always reaches your hooks. ScalingDecisionMadeandSlaBreachPredictedcarry the post-policy decision. They are dispatched afterafterScaling(), so listeners see what a policy actually produced.
Cluster scope: constraints that mean the whole fleet
Every decision described above is host-scoped: its worker counts are one manager's share, and
$decision->scope is ScalingScope::Host. In cluster mode that has a sharp consequence for a
policy expressing a global budget (an external API's concurrency ceiling, license seats, a provider
rate limit): the leader distributes the cluster-wide target across hosts first, and each host then
runs the policy chain on its own share. A cap of N applied per host allows N x host_count
workers across the cluster.
A policy that needs to clamp the cluster total implements the opt-in marker interface:
use Cbox\LaravelQueueAutoscale\Contracts\ClusterScopedPolicy;
use Cbox\LaravelQueueAutoscale\Scaling\ScalingDecision;
use Cbox\LaravelQueueAutoscale\Scaling\ScalingScope;
class ExternalApiBudgetPolicy implements ClusterScopedPolicy
{
public function beforeScaling(ScalingDecision $decision): ?ScalingDecision
{
// Runs on the leader with cluster-wide counts, and on every host
// with that host's share. Cap once, where the cap means the fleet.
if ($decision->scope !== ScalingScope::Cluster) {
return null;
}
return $decision->withTargetWorkers(min($decision->targetWorkers, 30));
}
public function afterScaling(ScalingDecision $decision): void {}
}
Mechanics:
- The leader consults only policies implementing
ClusterScopedPolicy, against a decision withscope = ScalingScope::Cluster,reason = 'cluster:demand', and the workload's cluster-wide current and target counts, after demand evaluation and before fair-share allocation and distribution. Policies without the marker are never consulted there, so nothing changes for existing policies unless one explicitly opts in. - A cluster-scoped policy still runs on every host's apply path like any other policy, where the
scope is
Host. Check$decision->scopeand returnnullfor the scope you do not constrain, as above. - Use
withTargetWorkers()to adjust a target: it copies every other field, including the scope, so the next policy in the chain still sees a cluster decision. - Under capacity contention the fair-share allocator satisfies every workload's
workers.minfloor first, so a cluster cap below a workload's configured minimum is raised back to that minimum — as long as the floors fit. When the floors together exceed capacity they cannot all be honoured, and are scaled down proportionally instead;workers.minis a claim on a fair share of the cluster, not a reservation the hardware can be made to produce. - When capacity cannot satisfy everyone, the shortfall is shared proportionally and the leftover workers go to whoever is owed the most. Entitlement a workload did not receive is banked and carried forward, so proportionality holds over time rather than per cycle — no workload can be left at zero indefinitely, not even one whose share rounds to nothing. A workload already holding a leftover keeps it until a challenger has banked meaningfully more, so the guarantee does not turn into churn.
- Single-host mode is unaffected: no cluster decisions exist there, and a cluster-scoped policy simply sees the same host-scoped decisions as any other policy.
Registration and resolution
Policies are configured as a list of class strings in config/queue-autoscale.php:
'policies' => [
\Cbox\LaravelQueueAutoscale\Policies\ConservativeScaleDownPolicy::class,
\Cbox\LaravelQueueAutoscale\Policies\BreachNotificationPolicy::class,
\App\Autoscale\Policies\BusinessHoursPolicy::class,
],
AutoscaleConfiguration::policyClasses() maps every entry through
is_string($policy) && class_exists($policy) and drops everything else. That has one sharp edge:
// ❌ Silently ignored — never constructed, never called, no warning is logged.
'policies' => [
new \App\Autoscale\Policies\SlackNotificationPolicy('https://hooks.slack.com/...'),
fn (ScalingDecision $decision) => $decision,
],
A pre-built instance, a closure, or a class string that does not autoload is filtered out
before PolicyExecutor ever sees it. Only class strings work.
Each surviving class string is resolved with app($class), so constructor injection works — that is
how NoScaleDownPolicy receives its CapacityCalculator. Pass your own configuration by binding the
policy in a service provider:
// AppServiceProvider::register()
$this->app->bind(\App\Autoscale\Policies\SlackNotificationPolicy::class, function (): object {
return new \App\Autoscale\Policies\SlackNotificationPolicy(
webhookUrl: config('services.slack.autoscale_webhook'),
minWorkerChange: 5,
);
});
If a resolved object does not implement ScalingPolicy it is skipped and a warning is written to
AutoscaleConfiguration::logChannel() (queue-autoscale.manager.log_channel, default stack).
PolicyExecutor is registered as a container singleton and loads its policy list once, in its
constructor. Changing queue-autoscale.policies at runtime has no effect until the manager restarts.
Chaining semantics
PolicyExecutor::beforeScaling() threads one decision through every policy in configuration order:
$currentDecision = $decision;
foreach ($this->policies as $policy) {
$modifiedDecision = $policy->beforeScaling($currentDecision);
if ($modifiedDecision !== null) {
$currentDecision = $modifiedDecision;
}
}
return $currentDecision;
- Returning
nullmeans "no opinion" — the chain continues with the decision unchanged. - Returning a
ScalingDecisionreplaces the working decision for every later policy and for the scaling action itself. - Order therefore matters. A policy that widens a scale-down placed after one that narrows it wins,
which is exactly how
AggressiveScaleDownPolicyis meant to overrideConservativeScaleDownPolicy.
afterScaling() is different: it is a plain fan-out. Every policy receives the same final decision,
return values are ignored, and nothing can be changed at that point.
Rebuilding a decision correctly
The constructor takes named arguments and defaults the last four. Anything you do not copy across is
silently reset — most commonly capacity and spawnCompensation, and dropping the latter means the
spawn path falls back to QueueConfiguration::fromConfig() to recover it.
use Cbox\LaravelQueueAutoscale\Contracts\ScalingPolicy;
use Cbox\LaravelQueueAutoscale\Scaling\ScalingDecision;
use Cbox\LaravelQueueAutoscale\Scaling\DTOs\LimitingFactor;
final class MinimumDuringBusinessHoursPolicy implements ScalingPolicy
{
public function __construct(private readonly int $floor = 5) {}
public function beforeScaling(ScalingDecision $decision): ?ScalingDecision
{
$hour = now()->hour;
if ($hour < 9 || $hour > 17 || $decision->targetWorkers >= $this->floor) {
return null;
}
return new ScalingDecision(
connection: $decision->connection,
queue: $decision->queue,
currentWorkers: $decision->currentWorkers,
targetWorkers: $this->floor,
reason: sprintf(
'BusinessHoursPolicy raised target to %d (original: %s)',
$this->floor,
$decision->reason,
),
predictedPickupTime: $decision->predictedPickupTime,
slaTarget: $decision->slaTarget,
capacity: $decision->capacity,
spawnCompensation: $decision->spawnCompensation,
);
}
public function afterScaling(ScalingDecision $decision): void
{
// No side effects.
}
}
Error isolation
Both hooks are wrapped per policy:
try {
$modifiedDecision = $policy->beforeScaling($currentDecision);
// ...
} catch (\Throwable $e) {
Log::channel(AutoscaleConfiguration::logChannel())->error('Policy beforeScaling failed', [
'policy' => get_class($policy),
'error' => $e->getMessage(),
]);
}
A throwing policy is logged and skipped. The chain continues with the last good decision, the
remaining policies still run, and scaling proceeds. You never need a try/catch inside a policy to
protect the autoscaler — add one only when you want a narrower log line or a fallback value.
The corollary: a policy that throws on every cycle is invisible unless you watch the configured log
channel. grep 'Policy beforeScaling failed' (and afterScaling) belongs in your log alerting.
Policies override the safety clamps
The engine clamps to capacity, then to [workers.min, workers.max], then to the fuse ceiling — and
then hands the decision to the policies. Nothing re-clamps afterwards. scaleUp() spawns
workersToAdd() processes for whatever target comes back.
So a policy returning targetWorkers: 500 will spawn towards 500 regardless of workers.max, the
CPU/memory ceiling, and a tripped fuse. If your policy raises a target, clamp it yourself:
$config = \Cbox\LaravelQueueAutoscale\Configuration\QueueConfiguration::fromConfig(
$decision->connection,
$decision->queue,
);
$target = min(max($proposed, $config->workers->min), $config->workers->max);
This matters most for the failure fuse. When the fuse is open the engine has already pinned the
target down to the fuse ceiling and set $decision->capacity->limitingFactor to LimitingFactor::Fuse. A policy
that unconditionally raises the target will scale workers into the downstream outage the fuse was
protecting you from. Check the limiting factor before overriding:
if ($decision->capacity?->limitingFactor === LimitingFactor::Fuse) {
return null; // Leave a fuse-limited decision alone.
}
Reading $decision->capacity
CapacityCalculationResult on a decision reports:
| Field | Meaning |
|---|---|
maxWorkersByCpu |
Host CPU ceiling from CapacityCalculator |
maxWorkersByMemory |
Host memory ceiling from CapacityCalculator |
maxWorkersByConfig |
workers.max for this queue |
finalMaxWorkers |
The engine's final target — the same number as targetWorkers, not the raw system maximum |
limitingFactor |
'config' | 'strategy' | 'cpu' | 'memory' | 'balanced' | 'fuse' | 'system_metrics_unavailable' |
details |
Raw sample values used for the calculation |
If you need the untouched host ceiling rather than the final target, resolve
CapacityCalculator yourself — see NoScaleDownPolicy below.
The shipped policies
Defaults in config/queue-autoscale.php are ConservativeScaleDownPolicy followed by
BreachNotificationPolicy.
ConservativeScaleDownPolicy
Ignores anything that is not a scale-down. For scale-downs it computes
$maxRemovable = max(1, (int) ceil($decision->currentWorkers * 0.25));
— 25% of the current worker count, with a floor of 1 — and if the decision removes more than
that, rebuilds it with targetWorkers: currentWorkers - $maxRemovable. Otherwise it returns null.
Convergence towards an idle target is therefore geometric, not one worker per cycle. Starting from 40 workers with a strategy target of 0:
40 → 30 → 22 → 16 → 12 → 9 → 6 → 4 → 3 → 2 → 1 → 0
The tail is where the max(1, ...) floor takes over: below 4 workers, 25% rounds up to exactly one
worker per cycle.
The rebuilt decision copies predictedPickupTime and slaTarget but not capacity or
spawnCompensation, so a decision modified by this policy reaches later policies with
capacity === null. Guard with $decision->capacity?-> when you place a policy after it.
afterScaling() does nothing.
AggressiveScaleDownPolicy
Also scale-down only. It forces the strategy's exact target when both of these hold:
predictedPickupTimeisnullor0.0(nothing is waiting), andtargetWorkers <= 1.
In that case it returns a decision with targetWorkers unchanged from the incoming value but a new
reason — the point being that it replaces a target that an earlier ConservativeScaleDownPolicy
narrowed. In every other case it returns null, which is what lets a full-size scale-down through
when Conservative is not in the list.
That is why the class is designed to sit after Conservative rather than instead of being its peer, and why listing it alone is the normal configuration:
'policies' => [
\Cbox\LaravelQueueAutoscale\Policies\AggressiveScaleDownPolicy::class,
\Cbox\LaravelQueueAutoscale\Policies\BreachNotificationPolicy::class,
],
It copies capacity onto the decision it returns. afterScaling() does nothing.
NoScaleDownPolicy
Blocks scale-down by returning a decision with targetWorkers: $decision->currentWorkers, with one
deliberate exception. It receives a CapacityCalculator through constructor injection and asks it
for the host ceiling on every scale-down:
$capacityResult = $this->capacity->calculateMaxWorkers(
$decision->currentWorkers,
ResourceEstimate::globalDefault(),
);
if ($decision->currentWorkers > $capacityResult->finalMaxWorkers) {
return null; // Resource-forced scale-down proceeds.
}
If the host can no longer support the workers that are already running, the scale-down is let through to keep the machine stable. Everything else is held.
Note that it evaluates capacity with ResourceEstimate::globalDefault() — the global
limits.worker_cpu_core_estimate / limits.worker_memory_mb_estimate values — not any per-queue
resources override.
Because CapacityCalculator caches system metrics for a few seconds, this adds no measurable cost
per cycle. It is not registered by default; add the class string to policies to enable it.
BreachNotificationPolicy
beforeScaling() always returns null — this policy never modifies a decision. All of its work is
in afterScaling():
- When
$decision->isSlaBreachRisk()is true it logsSLA BREACH RISK DETECTEDat warning level with connection, queue, predicted pickup time, SLA target, current/target workers and reason. - When
predictedPickupTime !== nullandpredictedPickupTime / slaTarget >= 0.90it logsHigh SLA utilization: NN.N%at notice level.
Both are gated through AlertRateLimiter on keys breach_risk:{connection}:{queue} and
high_util:{connection}:{queue}, using queue-autoscale.alerting.cooldown_seconds (default 300).
Without that gate a persistent breach would log on every evaluation cycle; with it you get at most
one line per queue per condition per cooldown window.
Everything goes to AutoscaleConfiguration::logChannel().
To add your own delivery channel, write a second policy alongside it rather than extending it —
inheriting from a shipped class couples you to its internals, and its AlertRateLimiter is a
private promoted property a subclass could not reuse anyway. The reusable piece is
AlertRateLimiter itself: allow(string $key) returns false while the key is still inside its
cooldown, backed by an atomic Cache::lock, so it is safe across processes and hosts.
use Cbox\LaravelQueueAutoscale\Alerting\AlertRateLimiter;
use Cbox\LaravelQueueAutoscale\Contracts\ScalingPolicy;
use Cbox\LaravelQueueAutoscale\Scaling\ScalingDecision;
use Illuminate\Support\Facades\Http;
final readonly class SlackBreachNotificationPolicy implements ScalingPolicy
{
public function __construct(
private AlertRateLimiter $limiter = new AlertRateLimiter(cooldownSeconds: 900),
) {}
public function beforeScaling(ScalingDecision $decision): ?ScalingDecision
{
return null;
}
public function afterScaling(ScalingDecision $decision): void
{
if (! $decision->isSlaBreachRisk()) {
return;
}
if (! $this->limiter->allow("slack:breach:{$decision->connection}:{$decision->queue}")) {
return;
}
Http::timeout(5)->post(config('services.slack.autoscale_webhook'), [
'text' => sprintf(
'%s:%s predicted pickup %.1fs exceeds SLA %ds',
$decision->connection,
$decision->queue,
$decision->predictedPickupTime ?? 0.0,
$decision->slaTarget,
),
]);
}
}
'policies' => [
\Cbox\LaravelQueueAutoscale\Policies\ConservativeScaleDownPolicy::class,
\Cbox\LaravelQueueAutoscale\Policies\BreachNotificationPolicy::class,
\App\Autoscale\Policies\SlackBreachNotificationPolicy::class,
],
Both policies then run in afterScaling(), each with its own cooldown. Alternatively, skip the
policy layer entirely and listen for SlaBreachPredicted — see
Event Handling.
Testing a policy
Policies are plain objects over a readonly DTO, so they unit-test without the container:
use App\Autoscale\Policies\MinimumDuringBusinessHoursPolicy;
use Cbox\LaravelQueueAutoscale\Scaling\ScalingDecision;
it('raises a low target during business hours', function (): void {
$this->travelTo(now()->setTime(10, 0));
$decision = new ScalingDecision(
connection: 'redis',
queue: 'default',
currentWorkers: 2,
targetWorkers: 2,
reason: 'steady state',
predictedPickupTime: 4.0,
slaTarget: 30,
);
$result = (new MinimumDuringBusinessHoursPolicy(floor: 5))->beforeScaling($decision);
expect($result)->not->toBeNull()
->and($result->targetWorkers)->toBe(5)
->and($result->reason)->toContain('BusinessHoursPolicy');
});
To exercise the chain, resolve the executor after setting the config:
use Cbox\LaravelQueueAutoscale\Policies\ConservativeScaleDownPolicy;
use Cbox\LaravelQueueAutoscale\Policies\PolicyExecutor;
use Cbox\LaravelQueueAutoscale\Scaling\ScalingDecision;
it('limits scale-down to 25 percent of current workers', function (): void {
config()->set('queue-autoscale.policies', [ConservativeScaleDownPolicy::class]);
$decision = new ScalingDecision(
connection: 'redis',
queue: 'default',
currentWorkers: 40,
targetWorkers: 4,
reason: 'queue drained',
);
$final = app(PolicyExecutor::class)->beforeScaling($decision);
expect($final->targetWorkers)->toBe(30);
});
PolicyExecutor is a singleton that reads the policy list in its constructor, so set the config
before the first app(PolicyExecutor::class) call in the test (or call
app()->forgetInstance(PolicyExecutor::class)).
See Also
- Scaling Policies - Introduction and the shipped policy catalogue
- Custom Strategies - The other extension point, running before the engine
- Failure Fuse - What the fuse ceiling protects
- Event Handling - Reacting to decisions without a policy
- Architecture - The full evaluation pipeline