272 lines
8.7 KiB
PHP
272 lines
8.7 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\OperationRun;
|
|
use App\Models\RestoreRun;
|
|
use App\Support\OperationRunOutcome;
|
|
use App\Support\OperationRunStatus;
|
|
use App\Support\RestoreRunStatus;
|
|
use Carbon\CarbonImmutable;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
|
|
final class AdapterRunReconciler
|
|
{
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
public function supportedTypes(): array
|
|
{
|
|
return [
|
|
'restore.execute',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array{type?: string|null, tenant_id?: int|null, older_than_minutes?: 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
|
|
{
|
|
$type = $options['type'] ?? null;
|
|
$tenantId = $options['tenant_id'] ?? null;
|
|
$olderThanMinutes = max(1, (int) ($options['older_than_minutes'] ?? 10));
|
|
$limit = max(1, (int) ($options['limit'] ?? 50));
|
|
$dryRun = (bool) ($options['dry_run'] ?? true);
|
|
|
|
if ($type !== null && ! in_array($type, $this->supportedTypes(), true)) {
|
|
throw new \InvalidArgumentException('Unsupported adapter run type: '.$type);
|
|
}
|
|
|
|
$cutoff = CarbonImmutable::now()->subMinutes($olderThanMinutes);
|
|
|
|
$query = OperationRun::query()
|
|
->whereIn('type', $type ? [$type] : $this->supportedTypes())
|
|
->whereIn('status', [OperationRunStatus::Queued->value, OperationRunStatus::Running->value])
|
|
->whereNotNull('context->restore_run_id')
|
|
->where(function (Builder $q) use ($cutoff): void {
|
|
$q
|
|
->where(function (Builder $q2) use ($cutoff): void {
|
|
$q2->whereNull('started_at')->where('created_at', '<', $cutoff);
|
|
})
|
|
->orWhere(function (Builder $q2) use ($cutoff): void {
|
|
$q2->whereNotNull('started_at')->where('started_at', '<', $cutoff);
|
|
});
|
|
})
|
|
->orderBy('id')
|
|
->limit($limit);
|
|
|
|
if (is_int($tenantId) && $tenantId > 0) {
|
|
$query->where('tenant_id', $tenantId);
|
|
}
|
|
|
|
$candidates = $query->get();
|
|
|
|
$changes = [];
|
|
$reconciled = 0;
|
|
$skipped = 0;
|
|
|
|
foreach ($candidates as $run) {
|
|
$change = $this->reconcileOne($run, $dryRun);
|
|
|
|
if ($change === null) {
|
|
$skipped++;
|
|
|
|
continue;
|
|
}
|
|
|
|
$changes[] = $change;
|
|
|
|
if (($change['applied'] ?? false) === true) {
|
|
$reconciled++;
|
|
}
|
|
}
|
|
|
|
return [
|
|
'candidates' => $candidates->count(),
|
|
'reconciled' => $reconciled,
|
|
'skipped' => $skipped,
|
|
'changes' => $changes,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>|null
|
|
*/
|
|
private function reconcileOne(OperationRun $run, bool $dryRun): ?array
|
|
{
|
|
if ($run->type !== 'restore.execute') {
|
|
return null;
|
|
}
|
|
|
|
$context = is_array($run->context) ? $run->context : [];
|
|
$restoreRunId = $context['restore_run_id'] ?? null;
|
|
|
|
if (! is_numeric($restoreRunId)) {
|
|
return null;
|
|
}
|
|
|
|
$restoreRun = RestoreRun::query()
|
|
->where('tenant_id', $run->tenant_id)
|
|
->whereKey((int) $restoreRunId)
|
|
->first();
|
|
|
|
if (! $restoreRun instanceof RestoreRun) {
|
|
return null;
|
|
}
|
|
|
|
$restoreStatus = RestoreRunStatus::fromString($restoreRun->status);
|
|
|
|
if (! $this->isTerminalRestoreStatus($restoreStatus)) {
|
|
return null;
|
|
}
|
|
|
|
[$opStatus, $opOutcome, $failures] = $this->mapRestoreToOperationRun($restoreRun, $restoreStatus);
|
|
|
|
$summaryCounts = $this->buildSummaryCounts($restoreRun);
|
|
|
|
$before = [
|
|
'status' => (string) $run->status,
|
|
'outcome' => (string) $run->outcome,
|
|
];
|
|
|
|
$after = [
|
|
'status' => $opStatus,
|
|
'outcome' => $opOutcome,
|
|
];
|
|
|
|
if ($dryRun) {
|
|
return [
|
|
'applied' => false,
|
|
'operation_run_id' => (int) $run->getKey(),
|
|
'type' => (string) $run->type,
|
|
'restore_run_id' => (int) $restoreRun->getKey(),
|
|
'before' => $before,
|
|
'after' => $after,
|
|
];
|
|
}
|
|
|
|
/** @var OperationRunService $runs */
|
|
$runs = app(OperationRunService::class);
|
|
|
|
$runs->updateRun(
|
|
$run,
|
|
status: $opStatus,
|
|
outcome: $opOutcome,
|
|
summaryCounts: $summaryCounts,
|
|
failures: $failures,
|
|
);
|
|
|
|
$run->refresh();
|
|
|
|
$updatedContext = is_array($run->context) ? $run->context : [];
|
|
$reconciliation = is_array($updatedContext['reconciliation'] ?? null) ? $updatedContext['reconciliation'] : [];
|
|
$reconciliation['reconciled_at'] = CarbonImmutable::now()->toIso8601String();
|
|
$reconciliation['reason'] = 'adapter_out_of_sync';
|
|
|
|
$updatedContext['reconciliation'] = $reconciliation;
|
|
|
|
$run->context = $updatedContext;
|
|
|
|
if ($run->started_at === null && $restoreRun->started_at !== null) {
|
|
$run->started_at = $restoreRun->started_at;
|
|
}
|
|
|
|
if ($run->completed_at === null && $restoreRun->completed_at !== null) {
|
|
$run->completed_at = $restoreRun->completed_at;
|
|
}
|
|
|
|
$run->save();
|
|
|
|
return [
|
|
'applied' => true,
|
|
'operation_run_id' => (int) $run->getKey(),
|
|
'type' => (string) $run->type,
|
|
'restore_run_id' => (int) $restoreRun->getKey(),
|
|
'before' => $before,
|
|
'after' => $after,
|
|
];
|
|
}
|
|
|
|
private function isTerminalRestoreStatus(?RestoreRunStatus $status): bool
|
|
{
|
|
if (! $status instanceof RestoreRunStatus) {
|
|
return false;
|
|
}
|
|
|
|
return in_array($status, [
|
|
RestoreRunStatus::Completed,
|
|
RestoreRunStatus::Partial,
|
|
RestoreRunStatus::Failed,
|
|
RestoreRunStatus::Cancelled,
|
|
RestoreRunStatus::Aborted,
|
|
RestoreRunStatus::CompletedWithErrors,
|
|
], true);
|
|
}
|
|
|
|
/**
|
|
* @return array{0:string,1:string,2:array<int,array{code:string,message:string}>}
|
|
*/
|
|
private function mapRestoreToOperationRun(RestoreRun $restoreRun, RestoreRunStatus $status): array
|
|
{
|
|
$failureReason = is_string($restoreRun->failure_reason ?? null) ? (string) $restoreRun->failure_reason : '';
|
|
|
|
return match ($status) {
|
|
RestoreRunStatus::Completed => [OperationRunStatus::Completed->value, OperationRunOutcome::Succeeded->value, []],
|
|
RestoreRunStatus::Partial, RestoreRunStatus::CompletedWithErrors => [
|
|
OperationRunStatus::Completed->value,
|
|
OperationRunOutcome::PartiallySucceeded->value,
|
|
[[
|
|
'code' => 'restore.completed_with_warnings',
|
|
'message' => $failureReason !== '' ? $failureReason : 'Restore completed with warnings.',
|
|
]],
|
|
],
|
|
RestoreRunStatus::Failed, RestoreRunStatus::Aborted => [
|
|
OperationRunStatus::Completed->value,
|
|
OperationRunOutcome::Failed->value,
|
|
[[
|
|
'code' => 'restore.failed',
|
|
'message' => $failureReason !== '' ? $failureReason : 'Restore failed.',
|
|
]],
|
|
],
|
|
RestoreRunStatus::Cancelled => [
|
|
OperationRunStatus::Completed->value,
|
|
OperationRunOutcome::Failed->value,
|
|
[[
|
|
'code' => 'restore.cancelled',
|
|
'message' => 'Restore run was cancelled.',
|
|
]],
|
|
],
|
|
default => [OperationRunStatus::Running->value, OperationRunOutcome::Pending->value, []],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @return array<string, int>
|
|
*/
|
|
private function buildSummaryCounts(RestoreRun $restoreRun): array
|
|
{
|
|
$metadata = is_array($restoreRun->metadata) ? $restoreRun->metadata : [];
|
|
|
|
$counts = [];
|
|
|
|
foreach (['total', 'processed', 'succeeded', 'failed', 'skipped'] as $key) {
|
|
if (array_key_exists($key, $metadata) && is_numeric($metadata[$key])) {
|
|
$counts[$key] = (int) $metadata[$key];
|
|
}
|
|
}
|
|
|
|
if (! isset($counts['processed'])) {
|
|
$processed = (int) ($counts['succeeded'] ?? 0) + (int) ($counts['failed'] ?? 0) + (int) ($counts['skipped'] ?? 0);
|
|
|
|
if ($processed > 0) {
|
|
$counts['processed'] = $processed;
|
|
}
|
|
}
|
|
|
|
return $counts;
|
|
}
|
|
}
|