*/ 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>} */ 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|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} */ 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 */ 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; } }