$subjects * @param array{max_items_per_run: int, max_concurrency: int, max_retries: int} $budgets * @return array{ * stats: array{requested: int, succeeded: int, skipped: int, failed: int, throttled: int}, * gaps: array, * gap_subjects: list>, * resume_token: ?string, * captured_versions: array * } */ public function capture( Tenant $tenant, array $subjects, PolicyVersionCapturePurpose $purpose, array $budgets, ?string $resumeToken = null, ?int $operationRunId = null, ?int $baselineProfileId = null, ?string $createdBy = null, ): array { $subjects = array_values($subjects); $maxItemsPerRun = max(0, (int) ($budgets['max_items_per_run'] ?? 0)); $maxConcurrency = max(1, (int) ($budgets['max_concurrency'] ?? 1)); $maxRetries = max(0, (int) ($budgets['max_retries'] ?? 0)); $offset = 0; if (is_string($resumeToken) && $resumeToken !== '') { $state = BaselineEvidenceResumeToken::decode($resumeToken) ?? []; $offset = is_numeric($state['offset'] ?? null) ? max(0, (int) $state['offset']) : 0; } if ($offset >= count($subjects)) { $offset = 0; } $remaining = array_slice($subjects, $offset); $batch = $maxItemsPerRun > 0 ? array_slice($remaining, 0, $maxItemsPerRun) : []; $stats = [ 'requested' => count($batch), 'succeeded' => 0, 'skipped' => 0, 'failed' => 0, 'throttled' => 0, ]; /** @var array $gaps */ $gaps = []; /** @var list> $gapSubjects */ $gapSubjects = []; $capturedVersions = []; /** * @var array $seen */ $seen = []; foreach (array_chunk($batch, $maxConcurrency) as $chunk) { foreach ($chunk as $subject) { $policyType = trim((string) ($subject['policy_type'] ?? '')); $externalId = trim((string) ($subject['subject_external_id'] ?? '')); $subjectKey = trim((string) ($subject['subject_key'] ?? '')); $descriptor = $this->resolver()->describeForCapture( $policyType !== '' ? $policyType : 'unknown', $externalId !== '' ? $externalId : null, $subjectKey !== '' ? $subjectKey : null, ); if ($policyType === '' || $externalId === '') { $this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->invalidSubject($descriptor)); $stats['failed']++; continue; } $captureKey = $policyType.'|'.$externalId; if (isset($seen[$captureKey])) { $this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->duplicateSubject($descriptor)); $stats['skipped']++; continue; } $seen[$captureKey] = true; if ( $descriptor->resolutionPath === ResolutionPath::FoundationInventory || $descriptor->resolutionPath === ResolutionPath::Inventory ) { $this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->structuralInventoryOnly($descriptor)); $stats['skipped']++; continue; } $policy = Policy::query() ->where('tenant_id', (int) $tenant->getKey()) ->where('policy_type', $policyType) ->where('external_id', $externalId) ->first(); if (! $policy instanceof Policy) { $this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->missingExpectedRecord($descriptor)); $stats['failed']++; continue; } $attempt = 0; $result = null; while (true) { try { $result = $this->captureOrchestrator->capture( policy: $policy, tenant: $tenant, includeAssignments: true, includeScopeTags: true, createdBy: $createdBy, metadata: [ 'capture_source' => 'baseline_evidence', ], capturePurpose: $purpose, operationRunId: $operationRunId, baselineProfileId: $baselineProfileId, ); } catch (Throwable $throwable) { $result = [ 'failure' => [ 'reason' => $throwable->getMessage(), 'status' => is_numeric($throwable->getCode()) ? (int) $throwable->getCode() : null, ], ]; } if (! (is_array($result) && array_key_exists('failure', $result))) { $stats['succeeded']++; $version = $result['version'] ?? null; if ($version instanceof PolicyVersion) { $capturedVersions[$captureKey] = [ 'policy_type' => $policyType, 'subject_external_id' => $externalId, 'version' => $version, 'observed_at' => now()->toIso8601String(), 'observed_operation_run_id' => $operationRunId, ]; } break; } $failure = is_array($result['failure'] ?? null) ? $result['failure'] : []; $status = is_numeric($failure['status'] ?? null) ? (int) $failure['status'] : null; $isThrottled = in_array($status, [429, 503], true); if ($isThrottled && $attempt < $maxRetries) { $delayMs = $this->retryDelayMs($attempt); usleep($delayMs * 1000); $attempt++; continue; } if ($isThrottled) { $this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->throttled($descriptor)); $stats['throttled']++; } else { $this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->captureFailed($descriptor)); $stats['failed']++; } break; } } } $processed = $offset + count($batch); $resumeTokenOut = null; if ($processed < count($subjects)) { $resumeTokenOut = BaselineEvidenceResumeToken::encode([ 'offset' => $processed, 'total' => count($subjects), ]); $remainingCount = max(0, count($subjects) - $processed); if ($remainingCount > 0) { foreach (array_slice($subjects, $processed) as $remainingSubject) { $remainingPolicyType = trim((string) ($remainingSubject['policy_type'] ?? '')); $remainingExternalId = trim((string) ($remainingSubject['subject_external_id'] ?? '')); $remainingSubjectKey = trim((string) ($remainingSubject['subject_key'] ?? '')); if ($remainingPolicyType === '' || $remainingExternalId === '') { continue; } $remainingDescriptor = $this->resolver()->describeForCapture( $remainingPolicyType, $remainingExternalId, $remainingSubjectKey !== '' ? $remainingSubjectKey : null, ); $this->recordGap($gaps, $gapSubjects, $remainingDescriptor, $this->resolver()->budgetExhausted($remainingDescriptor)); } } } ksort($gaps); return [ 'stats' => $stats, 'gaps' => $gaps, 'gap_subjects' => $gapSubjects, 'resume_token' => $resumeTokenOut, 'captured_versions' => $capturedVersions, ]; } /** * @param array $gaps * @param list> $gapSubjects */ private function recordGap(array &$gaps, array &$gapSubjects, SubjectDescriptor $descriptor, ResolutionOutcomeRecord $outcome): void { $gaps[$outcome->reasonCode] = ($gaps[$outcome->reasonCode] ?? 0) + 1; $gapSubjects[] = array_merge($descriptor->toArray(), $outcome->toArray()); } private function resolver(): SubjectResolver { return $this->subjectResolver ?? app(SubjectResolver::class); } private function retryDelayMs(int $attempt): int { $attempt = max(0, $attempt); $baseDelayMs = 500; $maxDelayMs = 30_000; $delayMs = (int) min($maxDelayMs, $baseDelayMs * (2 ** $attempt)); $jitterMs = random_int(0, 250); return $delayMs + $jitterMs; } }