TenantAtlas/app/Services/Operations/OperationLifecyclePolicyValidator.php
2026-03-23 22:52:37 +01:00

140 lines
4.5 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\Operations;
use App\Jobs\Concerns\BridgesFailedOperationRun;
use App\Support\Operations\OperationLifecyclePolicy;
use RuntimeException;
final class OperationLifecyclePolicyValidator
{
public function __construct(
private readonly OperationLifecyclePolicy $policy,
) {}
/**
* @return array{
* valid:bool,
* errors:array<int, string>,
* definitions:array<string, array<string, mixed>>
* }
*/
public function validate(): array
{
$errors = [];
$definitions = [];
foreach ($this->policy->coveredTypeNames() as $operationType) {
$definition = $this->policy->definition($operationType);
if ($definition === null) {
$errors[] = sprintf('Missing lifecycle policy definition for [%s].', $operationType);
continue;
}
$definitions[$operationType] = $definition;
$jobClass = $this->policy->jobClass($operationType);
if ($jobClass === null || ! class_exists($jobClass)) {
$errors[] = sprintf('Lifecycle policy [%s] points to a missing job class.', $operationType);
continue;
}
$timeout = $this->jobTimeoutSeconds($operationType);
if (! is_int($timeout) || $timeout <= 0) {
$errors[] = sprintf('Lifecycle policy [%s] requires an explicit positive job timeout.', $operationType);
}
if (! $this->jobFailsOnTimeout($operationType)) {
$errors[] = sprintf('Lifecycle policy [%s] requires failOnTimeout=true.', $operationType);
}
if ($this->policy->requiresDirectFailedBridge($operationType) && ! $this->jobUsesDirectFailedBridge($operationType)) {
$errors[] = sprintf('Lifecycle policy [%s] requires a direct failed-job bridge.', $operationType);
}
$retryAfter = $this->policy->queueRetryAfterSeconds($this->policy->queueConnection($operationType));
$safetyMargin = $this->policy->retryAfterSafetyMarginSeconds();
if (is_int($timeout) && is_int($retryAfter) && $timeout >= ($retryAfter - $safetyMargin)) {
$errors[] = sprintf(
'Lifecycle policy [%s] has timeout %d which is not safely below retry_after %d (margin %d).',
$operationType,
$timeout,
$retryAfter,
$safetyMargin,
);
}
$expectedMaxRuntime = $this->policy->expectedMaxRuntimeSeconds($operationType);
if (is_int($expectedMaxRuntime) && is_int($retryAfter) && $expectedMaxRuntime >= ($retryAfter - $safetyMargin)) {
$errors[] = sprintf(
'Lifecycle policy [%s] expected runtime %d is not safely below retry_after %d (margin %d).',
$operationType,
$expectedMaxRuntime,
$retryAfter,
$safetyMargin,
);
}
}
return [
'valid' => $errors === [],
'errors' => $errors,
'definitions' => $definitions,
];
}
public function assertValid(): void
{
$result = $this->validate();
if (($result['valid'] ?? false) === true) {
return;
}
throw new RuntimeException(implode(' ', $result['errors'] ?? ['Lifecycle policy validation failed.']));
}
public function jobTimeoutSeconds(string $operationType): ?int
{
$jobClass = $this->policy->jobClass($operationType);
if ($jobClass === null || ! class_exists($jobClass)) {
return null;
}
$timeout = get_class_vars($jobClass)['timeout'] ?? null;
return is_numeric($timeout) ? (int) $timeout : null;
}
public function jobFailsOnTimeout(string $operationType): bool
{
$jobClass = $this->policy->jobClass($operationType);
if ($jobClass === null || ! class_exists($jobClass)) {
return false;
}
return (bool) (get_class_vars($jobClass)['failOnTimeout'] ?? false);
}
public function jobUsesDirectFailedBridge(string $operationType): bool
{
$jobClass = $this->policy->jobClass($operationType);
if ($jobClass === null || ! class_exists($jobClass)) {
return false;
}
return in_array(BridgesFailedOperationRun::class, class_uses_recursive($jobClass), true);
}
}