201 lines
7.1 KiB
PHP
201 lines
7.1 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\Operations;
|
|
|
|
use App\Models\OperationRun;
|
|
use App\Services\OperationRunService;
|
|
use App\Support\OperationRunOutcome;
|
|
use App\Support\OperationRunStatus;
|
|
use App\Support\Operations\LifecycleReconciliationReason;
|
|
use App\Support\Operations\OperationLifecyclePolicy;
|
|
use App\Support\Operations\OperationRunFreshnessState;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
|
|
final class OperationLifecycleReconciler
|
|
{
|
|
public function __construct(
|
|
private readonly OperationLifecyclePolicy $policy,
|
|
private readonly OperationRunService $operationRunService,
|
|
private readonly QueuedExecutionLegitimacyGate $queuedExecutionLegitimacyGate,
|
|
) {}
|
|
|
|
/**
|
|
* @param array{
|
|
* types?: array<int, string>,
|
|
* tenant_ids?: array<int, int>,
|
|
* workspace_ids?: array<int, int>,
|
|
* limit?: int,
|
|
* dry_run?: bool
|
|
* } $options
|
|
* @return array{candidates:int,reconciled:int,skipped:int,changes:array<int, array<string, mixed>>}
|
|
*/
|
|
public function reconcile(array $options = []): array
|
|
{
|
|
$types = array_values(array_filter(
|
|
$options['types'] ?? $this->policy->coveredTypeNames(),
|
|
static fn (mixed $type): bool => is_string($type) && trim($type) !== '',
|
|
));
|
|
$tenantIds = array_values(array_filter(
|
|
$options['tenant_ids'] ?? [],
|
|
static fn (mixed $tenantId): bool => is_int($tenantId) && $tenantId > 0,
|
|
));
|
|
$workspaceIds = array_values(array_filter(
|
|
$options['workspace_ids'] ?? [],
|
|
static fn (mixed $workspaceId): bool => is_int($workspaceId) && $workspaceId > 0,
|
|
));
|
|
$limit = min(max(1, (int) ($options['limit'] ?? $this->policy->reconciliationBatchLimit())), 500);
|
|
$dryRun = (bool) ($options['dry_run'] ?? false);
|
|
|
|
$runs = OperationRun::query()
|
|
->with(['tenant', 'user'])
|
|
->whereIn('type', $types)
|
|
->whereIn('status', [
|
|
OperationRunStatus::Queued->value,
|
|
OperationRunStatus::Running->value,
|
|
])
|
|
->when(
|
|
$tenantIds !== [],
|
|
fn (Builder $query): Builder => $query->whereIn('tenant_id', $tenantIds),
|
|
)
|
|
->when(
|
|
$workspaceIds !== [],
|
|
fn (Builder $query): Builder => $query->whereIn('workspace_id', $workspaceIds),
|
|
)
|
|
->orderBy('id')
|
|
->limit($limit)
|
|
->get();
|
|
|
|
$changes = [];
|
|
$reconciled = 0;
|
|
$skipped = 0;
|
|
|
|
foreach ($runs as $run) {
|
|
$change = $this->reconcileRun($run, $dryRun);
|
|
|
|
if ($change === null) {
|
|
$skipped++;
|
|
|
|
continue;
|
|
}
|
|
|
|
$changes[] = $change;
|
|
|
|
if (($change['applied'] ?? false) === true) {
|
|
$reconciled++;
|
|
}
|
|
}
|
|
|
|
return [
|
|
'candidates' => $runs->count(),
|
|
'reconciled' => $reconciled,
|
|
'skipped' => $skipped,
|
|
'changes' => $changes,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>|null
|
|
*/
|
|
public function reconcileRun(OperationRun $run, bool $dryRun = false): ?array
|
|
{
|
|
$assessment = $this->assessment($run);
|
|
|
|
if ($assessment === null || ($assessment['should_reconcile'] ?? false) !== true) {
|
|
return null;
|
|
}
|
|
|
|
$before = [
|
|
'status' => (string) $run->status,
|
|
'outcome' => (string) $run->outcome,
|
|
'freshness_state' => OperationRunFreshnessState::forRun($run, $this->policy)->value,
|
|
];
|
|
$after = [
|
|
'status' => OperationRunStatus::Completed->value,
|
|
'outcome' => OperationRunOutcome::Failed->value,
|
|
'freshness_state' => OperationRunFreshnessState::ReconciledFailed->value,
|
|
];
|
|
|
|
if ($dryRun) {
|
|
return [
|
|
'applied' => false,
|
|
'operation_run_id' => (int) $run->getKey(),
|
|
'type' => (string) $run->type,
|
|
'before' => $before,
|
|
'after' => $after,
|
|
'reason_code' => $assessment['reason_code'],
|
|
'reason_message' => $assessment['reason_message'],
|
|
'evidence' => $assessment['evidence'],
|
|
];
|
|
}
|
|
|
|
$updated = $this->operationRunService->forceFailNonTerminalRun(
|
|
run: $run,
|
|
reasonCode: (string) $assessment['reason_code'],
|
|
message: (string) $assessment['reason_message'],
|
|
source: 'scheduled_reconciler',
|
|
evidence: is_array($assessment['evidence'] ?? null) ? $assessment['evidence'] : [],
|
|
);
|
|
|
|
return [
|
|
'applied' => true,
|
|
'operation_run_id' => (int) $updated->getKey(),
|
|
'type' => (string) $updated->type,
|
|
'before' => $before,
|
|
'after' => $after,
|
|
'reason_code' => $assessment['reason_code'],
|
|
'reason_message' => $assessment['reason_message'],
|
|
'evidence' => $assessment['evidence'],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array{should_reconcile:bool,reason_code:string,reason_message:string,evidence:array<string, mixed>}|null
|
|
*/
|
|
public function assessment(OperationRun $run): ?array
|
|
{
|
|
if ((string) $run->status === OperationRunStatus::Completed->value) {
|
|
return null;
|
|
}
|
|
|
|
if (! $this->policy->supports((string) $run->type) || ! $this->policy->supportsScheduledReconciliation((string) $run->type)) {
|
|
return null;
|
|
}
|
|
|
|
$freshnessState = OperationRunFreshnessState::forRun($run, $this->policy);
|
|
|
|
if (! $freshnessState->isLikelyStale()) {
|
|
return null;
|
|
}
|
|
|
|
$reason = (string) $run->status === OperationRunStatus::Queued->value
|
|
? LifecycleReconciliationReason::StaleQueued
|
|
: LifecycleReconciliationReason::StaleRunning;
|
|
$referenceTime = (string) $run->status === OperationRunStatus::Queued->value
|
|
? $run->created_at
|
|
: ($run->started_at ?? $run->created_at);
|
|
$thresholdSeconds = (string) $run->status === OperationRunStatus::Queued->value
|
|
? $this->policy->queuedStaleAfterSeconds((string) $run->type)
|
|
: $this->policy->runningStaleAfterSeconds((string) $run->type);
|
|
$legitimacy = $this->queuedExecutionLegitimacyGate->evaluate($run)->toArray();
|
|
|
|
return [
|
|
'should_reconcile' => true,
|
|
'reason_code' => $reason->value,
|
|
'reason_message' => $reason->defaultMessage(),
|
|
'evidence' => [
|
|
'evaluated_at' => now()->toIso8601String(),
|
|
'freshness_state' => $freshnessState->value,
|
|
'threshold_seconds' => $thresholdSeconds,
|
|
'reference_time' => $referenceTime?->toIso8601String(),
|
|
'status' => (string) $run->status,
|
|
'execution_legitimacy' => $legitimacy,
|
|
'terminal_truth_path' => $this->policy->requiresDirectFailedBridge((string) $run->type)
|
|
? 'direct_and_scheduled'
|
|
: 'scheduled_only',
|
|
],
|
|
];
|
|
}
|
|
}
|