operationRun = $operationRun; } public static function dispatchTracked( BackupItem $backupItem, array $policyPayload, ?User $initiator = null, ): OperationRun { $tenant = $backupItem->tenant; if (! $tenant instanceof Tenant) { throw new RuntimeException('BackupItem tenant context is required to dispatch assignment fetch job.'); } /** @var OperationRunService $operationRunService */ $operationRunService = app(OperationRunService::class); $identityInputs = self::operationRunIdentityInputs( tenantId: (int) $tenant->getKey(), backupItemId: (int) $backupItem->getKey(), tenantExternalId: (string) ($tenant->external_id ?? ''), policyExternalId: (string) $backupItem->policy_identifier, ); $run = $operationRunService->ensureRunWithIdentityCooldown( tenant: $tenant, type: self::OPERATION_TYPE, identityInputs: $identityInputs, context: self::operationRunContext($backupItem), cooldownMinutes: self::DEDUPE_COOLDOWN_MINUTES, initiator: $initiator, ); if ($run->wasRecentlyCreated) { dispatch(new self( backupItemId: (int) $backupItem->getKey(), tenantExternalId: (string) ($tenant->external_id ?? ''), policyExternalId: (string) $backupItem->policy_identifier, policyPayload: $policyPayload, operationRun: $run, )); } return $run; } /** * Execute the job. */ public function handle( AssignmentBackupService $assignmentBackupService, OperationRunService $operationRunService, ): void { $backupItem = BackupItem::query() ->with('tenant') ->find($this->backupItemId); if (! $backupItem instanceof BackupItem) { Log::warning('FetchAssignmentsJob: BackupItem not found', [ 'backup_item_id' => $this->backupItemId, ]); return; } $tenant = $backupItem->tenant; if (! $tenant instanceof Tenant) { Log::warning('FetchAssignmentsJob: Tenant not found for BackupItem', [ 'backup_item_id' => $this->backupItemId, ]); return; } $fingerprint = AssignmentJobFingerprint::forFetch( backupItemId: (int) $backupItem->getKey(), tenantExternalId: $this->tenantExternalId, policyExternalId: $this->policyExternalId, ); $identityInputs = self::operationRunIdentityInputs( tenantId: (int) $tenant->getKey(), backupItemId: (int) $backupItem->getKey(), tenantExternalId: $this->tenantExternalId, policyExternalId: $this->policyExternalId, ); if (! $this->operationRun instanceof OperationRun) { $this->operationRun = $operationRunService->ensureRunWithIdentityCooldown( tenant: $tenant, type: self::OPERATION_TYPE, identityInputs: $identityInputs, context: self::operationRunContext($backupItem), cooldownMinutes: self::DEDUPE_COOLDOWN_MINUTES, ); } $canonicalRun = $operationRunService->findCanonicalRunWithIdentity( tenant: $tenant, type: self::OPERATION_TYPE, identityInputs: $identityInputs, cooldownMinutes: self::DEDUPE_COOLDOWN_MINUTES, ); if ($canonicalRun instanceof OperationRun && $canonicalRun->status === OperationRunStatus::Completed->value) { Log::info('FetchAssignmentsJob: Skipping because identity is in cooldown window', [ 'backup_item_id' => (int) $backupItem->getKey(), 'operation_run_id' => (int) $canonicalRun->getKey(), ]); return; } if ($canonicalRun instanceof OperationRun && $this->operationRun instanceof OperationRun && (int) $canonicalRun->getKey() !== (int) $this->operationRun->getKey() ) { Log::info('FetchAssignmentsJob: Skipping non-canonical execution', [ 'backup_item_id' => (int) $backupItem->getKey(), 'canonical_run_id' => (int) $canonicalRun->getKey(), 'job_run_id' => (int) $this->operationRun->getKey(), ]); return; } $run = $this->operationRun instanceof OperationRun ? $this->operationRun : $canonicalRun; if (! $run instanceof OperationRun) { return; } $executionIdentityKey = AssignmentJobFingerprint::executionIdentityKey( jobType: self::OPERATION_TYPE, tenantId: (int) $tenant->getKey(), fingerprint: $fingerprint, operationRunId: (int) $run->getKey(), ); $lock = Cache::lock('assignment-job:'.$executionIdentityKey, self::EXECUTION_LOCK_TTL_SECONDS); if (! $lock->get()) { Log::info('FetchAssignmentsJob: Overlap prevented for duplicate execution', [ 'backup_item_id' => (int) $backupItem->getKey(), 'operation_run_id' => (int) $run->getKey(), 'execution_identity' => $executionIdentityKey, ]); return; } try { if ($run->status !== OperationRunStatus::Completed->value) { $operationRunService->updateRun($run, OperationRunStatus::Running->value); } if ($backupItem->policy_type !== 'settingsCatalogPolicy') { Log::info('FetchAssignmentsJob: Skipping non-Settings Catalog policy', [ 'backup_item_id' => (int) $backupItem->getKey(), 'policy_type' => (string) $backupItem->policy_type, ]); $this->completeRun( operationRunService: $operationRunService, run: $run, fetchFailed: false, ); return; } $assignmentBackupService->enrichWithAssignments( backupItem: $backupItem, tenant: $tenant, policyType: (string) $backupItem->policy_type, policyId: (string) $backupItem->policy_identifier, policyPayload: $this->policyPayload, includeAssignments: true, ); $backupItem->refresh(); $metadata = is_array($backupItem->metadata) ? $backupItem->metadata : []; $fetchFailed = (bool) ($metadata['assignments_fetch_failed'] ?? false); $reasonCandidate = $metadata['assignments_fetch_error_code'] ?? $metadata['assignments_fetch_error'] ?? ProviderReasonCodes::UnknownError; $reasonCode = RunFailureSanitizer::normalizeReasonCode( $this->normalizeReasonCandidate($reasonCandidate), ); $this->completeRun( operationRunService: $operationRunService, run: $run, fetchFailed: $fetchFailed, failures: $fetchFailed ? [[ 'code' => 'assignments.fetch_failed', 'reason_code' => $reasonCode, 'message' => (string) ($metadata['assignments_fetch_error'] ?? 'Assignments fetch failed.'), ]] : [], ); Log::info('FetchAssignmentsJob: Successfully processed', [ 'backup_item_id' => (int) $backupItem->getKey(), 'operation_run_id' => (int) $run->getKey(), 'assignment_count' => $backupItem->getAssignmentCountAttribute(), 'fetch_failed' => $fetchFailed, ]); } catch (\Throwable $throwable) { Log::error('FetchAssignmentsJob: Failed to enrich BackupItem', [ 'backup_item_id' => (int) $backupItem->getKey(), 'operation_run_id' => (int) $run->getKey(), 'error' => $throwable->getMessage(), ]); $safeMessage = RunFailureSanitizer::sanitizeMessage($throwable->getMessage()); $operationRunService->updateRun( $run, status: OperationRunStatus::Completed->value, outcome: OperationRunOutcome::Failed->value, summaryCounts: [ 'total' => 1, 'processed' => 0, 'failed' => 1, ], failures: [[ 'code' => 'assignments.fetch_failed', 'reason_code' => RunFailureSanitizer::normalizeReasonCode($throwable->getMessage()), 'message' => $safeMessage !== '' ? $safeMessage : 'Assignments fetch failed.', ]], ); } finally { $lock->release(); } } /** * @return array */ private static function operationRunIdentityInputs( int $tenantId, int $backupItemId, string $tenantExternalId, string $policyExternalId, ): array { return [ 'tenant_id' => $tenantId, 'job_type' => self::OPERATION_TYPE, 'fingerprint' => AssignmentJobFingerprint::forFetch( backupItemId: $backupItemId, tenantExternalId: $tenantExternalId, policyExternalId: $policyExternalId, ), ]; } /** * @return array */ private static function operationRunContext(BackupItem $backupItem): array { return [ 'backup_set_id' => is_numeric($backupItem->backup_set_id) ? (int) $backupItem->backup_set_id : null, 'backup_item_id' => (int) $backupItem->getKey(), 'policy_id' => is_numeric($backupItem->policy_id) ? (int) $backupItem->policy_id : null, 'policy_identifier' => (string) $backupItem->policy_identifier, ]; } /** * @param array $failures */ private function completeRun( OperationRunService $operationRunService, OperationRun $run, bool $fetchFailed, array $failures = [], ): void { $operationRunService->updateRun( $run, status: OperationRunStatus::Completed->value, outcome: $fetchFailed ? OperationRunOutcome::Failed->value : OperationRunOutcome::Succeeded->value, summaryCounts: [ 'total' => 1, 'processed' => $fetchFailed ? 0 : 1, 'failed' => $fetchFailed ? 1 : 0, ], failures: $failures, ); } private function normalizeReasonCandidate(mixed $reasonCandidate): string { if (is_string($reasonCandidate) && trim($reasonCandidate) !== '') { return trim($reasonCandidate); } return ProviderReasonCodes::UnknownError; } }