$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, * 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 = []; $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'] ?? '')); if ($policyType === '' || $externalId === '') { $gaps['invalid_subject'] = ($gaps['invalid_subject'] ?? 0) + 1; $stats['failed']++; continue; } $subjectKey = $policyType.'|'.$externalId; if (isset($seen[$subjectKey])) { $gaps['duplicate_subject'] = ($gaps['duplicate_subject'] ?? 0) + 1; $stats['skipped']++; continue; } $seen[$subjectKey] = true; $policy = Policy::query() ->where('tenant_id', (int) $tenant->getKey()) ->where('policy_type', $policyType) ->where('external_id', $externalId) ->first(); if (! $policy instanceof Policy) { $gaps['policy_not_found'] = ($gaps['policy_not_found'] ?? 0) + 1; $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[$subjectKey] = [ '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) { $gaps['throttled'] = ($gaps['throttled'] ?? 0) + 1; $stats['throttled']++; } else { $gaps['capture_failed'] = ($gaps['capture_failed'] ?? 0) + 1; $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) { $gaps['budget_exhausted'] = ($gaps['budget_exhausted'] ?? 0) + $remainingCount; } } ksort($gaps); return [ 'stats' => $stats, 'gaps' => $gaps, 'resume_token' => $resumeTokenOut, 'captured_versions' => $capturedVersions, ]; } 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; } }