*/ public function supportedTypes(): array { return $this->registry->supportedTypes(); } /** * @param array{type?: string|null, managed_environment_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 = $this->normalizeRequestedType($options['type'] ?? null); $tenantId = $options['managed_environment_id'] ?? null; $olderThanMinutes = max(1, (int) ($options['older_than_minutes'] ?? 10)); $limit = max(1, (int) ($options['limit'] ?? 50)); $dryRun = (bool) ($options['dry_run'] ?? true); $cutoff = CarbonImmutable::now()->subMinutes($olderThanMinutes); $candidateTypes = $this->candidateRawTypes($type); $query = OperationRun::query() ->whereIn('type', $candidateTypes) ->whereIn('status', [OperationRunStatus::Queued->value, OperationRunStatus::Running->value]) ->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('managed_environment_id', $tenantId); } $candidates = $query->get(); $changes = []; $reconciled = 0; $skipped = 0; foreach ($candidates as $run) { $change = $this->reconcileOperationRun($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 */ public function reconcileOperationRun(OperationRun $run, bool $dryRun = false): ?array { $adapter = $this->resolveAdapter($run); return $this->reconcileUsingAdapter( run: $run, result: $adapter?->reconcile($run), adapter: $adapter, dryRun: $dryRun, ); } /** * @return array|null */ public function reconcileOperationRunFailure(OperationRun $run, Throwable $throwable, bool $dryRun = false): ?array { $adapter = $this->resolveAdapter($run); return $this->reconcileUsingAdapter( run: $run, result: $adapter?->reconcileException($run, $throwable), adapter: $adapter, dryRun: $dryRun, ); } /** * @return array|null */ private function reconcileUsingAdapter( OperationRun $run, ?ReconciliationResult $result, ?OperationRunReconciliationAdapter $adapter, bool $dryRun, ): ?array { if (! $adapter instanceof OperationRunReconciliationAdapter || ! $result instanceof ReconciliationResult) { return null; } $before = [ 'status' => (string) $run->status, 'outcome' => (string) $run->outcome, ]; $after = $result->shouldFinalizeRun() ? [ 'status' => (string) $result->status, 'outcome' => (string) $result->outcome, ] : null; if ($dryRun) { return array_merge([ 'applied' => false, 'operation_run_id' => (int) $run->getKey(), 'type' => (string) $run->type, 'adapter' => $adapter->key(), 'before' => $before, ], $result->toArray(), $after !== null ? ['after' => $after] : []); } if (! $result->shouldFinalizeRun()) { return null; } $this->operationRuns->applyReconciliationResult( run: $run, result: $result, source: 'adapter_reconciler', adapter: $adapter->key(), ); $run->refresh(); $this->syncRestoreLifecycleTimestamps($run, $result); return array_merge([ 'applied' => true, 'operation_run_id' => (int) $run->getKey(), 'type' => (string) $run->type, 'adapter' => $adapter->key(), 'before' => $before, 'after' => $after ?? [], ], $result->toArray()); } private function resolveAdapter(OperationRun $run): ?OperationRunReconciliationAdapter { return $this->registry->forType($run->canonicalOperationType()); } /** * @return array */ private function candidateRawTypes(?string $type = null): array { $canonicalTypes = $type !== null ? [$type] : $this->supportedTypes(); return array_values(array_unique(array_merge( ...array_map( static fn (string $canonicalType): array => OperationCatalog::rawValuesForCanonical($canonicalType), $canonicalTypes, ), ))); } private function normalizeRequestedType(mixed $type): ?string { if (! is_string($type) || trim($type) === '') { return null; } $canonicalType = OperationCatalog::canonicalCode($type); if (! in_array($canonicalType, $this->supportedTypes(), true)) { throw new \InvalidArgumentException('Unsupported adapter run type: '.$type); } return $canonicalType; } private function syncRestoreLifecycleTimestamps(OperationRun $run, ReconciliationResult $result): void { if (! array_key_exists('restore_run_id', $result->evidence)) { return; } if ($run->started_at === null && is_string($result->evidence['restore_started_at'] ?? null)) { $run->started_at = $result->evidence['restore_started_at']; } if ($run->completed_at === null && is_string($result->evidence['restore_completed_at'] ?? null)) { $run->completed_at = $result->evidence['restore_completed_at']; } $run->save(); } }