Implemented sync capture backup operation semantics as requested. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #433
313 lines
14 KiB
PHP
313 lines
14 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support\Operations\Reconciliation;
|
|
|
|
use App\Models\BaselineSnapshot;
|
|
use App\Models\OperationRun;
|
|
use App\Support\Baselines\BaselineReasonCodes;
|
|
use App\Support\Baselines\BaselineSnapshotLifecycleState;
|
|
use App\Support\OperationCatalog;
|
|
use App\Support\OperationRunOutcome;
|
|
use App\Support\OperationRunStatus;
|
|
use App\Support\OperationRunType;
|
|
use App\Support\Operations\LifecycleReconciliationReason;
|
|
use Throwable;
|
|
|
|
final class BaselineCaptureReconciliationAdapter implements OperationRunReconciliationAdapter
|
|
{
|
|
public function key(): string
|
|
{
|
|
return 'baseline_capture';
|
|
}
|
|
|
|
public function supportedTypes(): array
|
|
{
|
|
return [OperationRunType::BaselineCapture->value];
|
|
}
|
|
|
|
public function supportsType(string $type): bool
|
|
{
|
|
return OperationCatalog::canonicalCode($type) === OperationRunType::BaselineCapture->value;
|
|
}
|
|
|
|
public function reconcile(OperationRun $run): ?ReconciliationResult
|
|
{
|
|
if (! $this->supportsType((string) $run->type)) {
|
|
return ReconciliationResult::unsupported(
|
|
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
|
|
reasonMessage: 'This adapter only supports baseline capture runs.',
|
|
evidence: [
|
|
'adapter' => $this->key(),
|
|
'type' => (string) $run->type,
|
|
],
|
|
);
|
|
}
|
|
|
|
$context = is_array($run->context) ? $run->context : [];
|
|
$profileId = is_numeric($context['baseline_profile_id'] ?? null) ? (int) $context['baseline_profile_id'] : null;
|
|
$reasonCode = $this->captureReasonCode($context);
|
|
$subjectsTotal = $this->intValue(data_get($context, 'baseline_capture.subjects_total'));
|
|
$resumeToken = data_get($context, 'baseline_capture.resume_token');
|
|
$gapsCount = $this->intValue(data_get($context, 'baseline_capture.gaps.count'));
|
|
$itemsCaptured = $this->intValue(data_get($context, 'result.items_captured'));
|
|
|
|
$evidence = [
|
|
'adapter' => $this->key(),
|
|
'operation_run_id' => (int) $run->getKey(),
|
|
'workspace_id' => (int) $run->workspace_id,
|
|
'baseline_profile_id' => $profileId,
|
|
'reason_code' => $reasonCode,
|
|
'subjects_total' => $subjectsTotal,
|
|
'gaps_count' => $gapsCount,
|
|
'items_captured' => $itemsCaptured,
|
|
'resume_token_present' => is_string($resumeToken) && trim($resumeToken) !== '',
|
|
];
|
|
|
|
[$snapshot, $scopeProblem] = $this->resolveSnapshot($run, $profileId);
|
|
|
|
if ($scopeProblem !== null) {
|
|
return ReconciliationResult::notReconciled(
|
|
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
|
|
reasonMessage: $scopeProblem,
|
|
evidence: $evidence,
|
|
);
|
|
}
|
|
|
|
if ($snapshot instanceof BaselineSnapshot) {
|
|
$itemsCaptured = max($itemsCaptured, $this->intValue(data_get($snapshot->summary_jsonb, 'total_items')));
|
|
$subjectsTotal = max($subjectsTotal, $itemsCaptured);
|
|
$gapsCount = max($gapsCount, $this->intValue(data_get($snapshot->summary_jsonb, 'gaps.count')));
|
|
$reasonCode ??= is_string(data_get($snapshot->completion_meta_jsonb, 'finalization_reason_code'))
|
|
? (string) data_get($snapshot->completion_meta_jsonb, 'finalization_reason_code')
|
|
: null;
|
|
|
|
$summaryCounts = $this->summaryCounts($subjectsTotal, $itemsCaptured);
|
|
$related = [
|
|
'type' => 'baseline_snapshot',
|
|
'id' => (int) $snapshot->getKey(),
|
|
'lifecycle_state' => $snapshot->lifecycleState()->value,
|
|
'baseline_profile_id' => (int) $snapshot->baseline_profile_id,
|
|
];
|
|
|
|
if ($snapshot->lifecycleState() === BaselineSnapshotLifecycleState::Complete) {
|
|
if ((is_string($resumeToken) && trim($resumeToken) !== '') || $gapsCount > 0 || $itemsCaptured < $subjectsTotal) {
|
|
return new ReconciliationResult(
|
|
decision: 'reconciled_partially_succeeded',
|
|
status: OperationRunStatus::Completed->value,
|
|
outcome: OperationRunOutcome::PartiallySucceeded->value,
|
|
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
|
|
reasonMessage: 'The baseline capture recorded a usable snapshot, but evidence gaps still limit it.',
|
|
evidence: $evidence + ['chosen_snapshot_id' => (int) $snapshot->getKey()],
|
|
related: $related,
|
|
summaryCounts: $summaryCounts,
|
|
failures: [[
|
|
'code' => 'baseline.capture.partial',
|
|
'reason_code' => LifecycleReconciliationReason::AdapterOutOfSync->value,
|
|
'message' => 'The baseline capture recorded a usable snapshot, but evidence gaps still limit it.',
|
|
]],
|
|
safeForAutoCompletion: true,
|
|
);
|
|
}
|
|
|
|
return ReconciliationResult::reconciledSucceeded(
|
|
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
|
|
reasonMessage: 'The baseline capture already produced a complete snapshot before the run finished updating.',
|
|
evidence: $evidence + ['chosen_snapshot_id' => (int) $snapshot->getKey()],
|
|
related: $related,
|
|
summaryCounts: $summaryCounts,
|
|
);
|
|
}
|
|
|
|
if ($reasonCode === BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS) {
|
|
return new ReconciliationResult(
|
|
decision: 'reconciled_partially_succeeded',
|
|
status: OperationRunStatus::Completed->value,
|
|
outcome: OperationRunOutcome::PartiallySucceeded->value,
|
|
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
|
|
reasonMessage: 'The baseline capture finished without a usable baseline because no governed subjects were in scope.',
|
|
evidence: $evidence + ['chosen_snapshot_id' => (int) $snapshot->getKey()],
|
|
related: $related,
|
|
summaryCounts: $summaryCounts,
|
|
failures: [[
|
|
'code' => 'baseline.capture.partial',
|
|
'reason_code' => LifecycleReconciliationReason::AdapterOutOfSync->value,
|
|
'message' => 'The baseline capture finished without a usable baseline because no governed subjects were in scope.',
|
|
]],
|
|
safeForAutoCompletion: true,
|
|
);
|
|
}
|
|
|
|
if ($snapshot->lifecycleState() === BaselineSnapshotLifecycleState::Building) {
|
|
return ReconciliationResult::notReconciled(
|
|
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
|
|
reasonMessage: 'The baseline snapshot is still building and does not prove final capture truth yet.',
|
|
evidence: $evidence + ['chosen_snapshot_id' => (int) $snapshot->getKey()],
|
|
related: $related,
|
|
);
|
|
}
|
|
|
|
return ReconciliationResult::failedUnrecoverable(
|
|
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
|
|
reasonMessage: 'The baseline capture created a snapshot row, but it never became usable baseline truth.',
|
|
evidence: $evidence + ['chosen_snapshot_id' => (int) $snapshot->getKey()],
|
|
related: $related,
|
|
summaryCounts: $summaryCounts,
|
|
);
|
|
}
|
|
|
|
if ($reasonCode !== null && in_array($reasonCode, $this->blockedReasonCodes(), true)) {
|
|
return ReconciliationResult::blocked(
|
|
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
|
|
reasonMessage: $this->blockedReasonMessage($reasonCode, (bool) data_get($context, 'baseline_capture.eligibility.changed_after_enqueue')),
|
|
evidence: $evidence,
|
|
summaryCounts: $this->summaryCounts($subjectsTotal, $itemsCaptured),
|
|
);
|
|
}
|
|
|
|
if ($reasonCode !== null && in_array($reasonCode, [
|
|
BaselineReasonCodes::SNAPSHOT_CAPTURE_FAILED,
|
|
BaselineReasonCodes::SNAPSHOT_COMPLETION_PROOF_FAILED,
|
|
BaselineReasonCodes::SNAPSHOT_INCOMPLETE,
|
|
BaselineReasonCodes::SNAPSHOT_LEGACY_NO_PROOF,
|
|
BaselineReasonCodes::SNAPSHOT_LEGACY_CONTRADICTORY,
|
|
], true)) {
|
|
return ReconciliationResult::failedUnrecoverable(
|
|
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
|
|
reasonMessage: 'The baseline capture stopped before it could prove a usable snapshot.',
|
|
evidence: $evidence,
|
|
summaryCounts: $this->summaryCounts($subjectsTotal, $itemsCaptured),
|
|
);
|
|
}
|
|
|
|
return ReconciliationResult::notReconciled(
|
|
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
|
|
reasonMessage: 'No current-scope baseline snapshot proof is available yet for this run.',
|
|
evidence: $evidence,
|
|
);
|
|
}
|
|
|
|
public function reconcileException(OperationRun $run, Throwable $throwable): ?ReconciliationResult
|
|
{
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @return array{0:BaselineSnapshot|null,1:?string}
|
|
*/
|
|
private function resolveSnapshot(OperationRun $run, ?int $profileId): array
|
|
{
|
|
$context = is_array($run->context) ? $run->context : [];
|
|
$snapshotId = is_numeric($context['baseline_snapshot_id'] ?? null)
|
|
? (int) $context['baseline_snapshot_id']
|
|
: (is_numeric(data_get($context, 'result.snapshot_id')) ? (int) data_get($context, 'result.snapshot_id') : null);
|
|
|
|
if ($snapshotId !== null) {
|
|
$candidate = BaselineSnapshot::query()->whereKey($snapshotId)->first();
|
|
|
|
if (! $candidate instanceof BaselineSnapshot) {
|
|
return [null, null];
|
|
}
|
|
|
|
if ((int) $candidate->workspace_id !== (int) $run->workspace_id || ($profileId !== null && (int) $candidate->baseline_profile_id !== $profileId)) {
|
|
return [null, 'The recorded baseline snapshot no longer matches the queued capture scope safely.'];
|
|
}
|
|
|
|
return [$candidate, null];
|
|
}
|
|
|
|
if ($profileId === null) {
|
|
return [null, null];
|
|
}
|
|
|
|
$candidates = BaselineSnapshot::query()
|
|
->where('workspace_id', (int) $run->workspace_id)
|
|
->where('baseline_profile_id', $profileId)
|
|
->where('completion_meta_jsonb->producer_run_id', (int) $run->getKey())
|
|
->orderByDesc('id')
|
|
->get();
|
|
|
|
if ($candidates->count() > 1) {
|
|
return [null, 'Multiple baseline snapshots point at this run, so reconciliation stays fail-closed.'];
|
|
}
|
|
|
|
return [$candidates->first(), null];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $context
|
|
*/
|
|
private function captureReasonCode(array $context): ?string
|
|
{
|
|
foreach ([
|
|
data_get($context, 'baseline_capture.reason_code'),
|
|
$context['reason_code'] ?? null,
|
|
data_get($context, 'result.snapshot_reason_code'),
|
|
] as $candidate) {
|
|
if (is_string($candidate) && trim($candidate) !== '') {
|
|
return trim($candidate);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
private function blockedReasonCodes(): array
|
|
{
|
|
return [
|
|
BaselineReasonCodes::CAPTURE_MISSING_SOURCE_TENANT,
|
|
BaselineReasonCodes::CAPTURE_PROFILE_NOT_ACTIVE,
|
|
BaselineReasonCodes::CAPTURE_ROLLOUT_DISABLED,
|
|
BaselineReasonCodes::CAPTURE_INVALID_SCOPE,
|
|
BaselineReasonCodes::CAPTURE_UNSUPPORTED_SCOPE,
|
|
BaselineReasonCodes::CAPTURE_INVENTORY_MISSING,
|
|
BaselineReasonCodes::CAPTURE_INVENTORY_BLOCKED,
|
|
BaselineReasonCodes::CAPTURE_INVENTORY_FAILED,
|
|
BaselineReasonCodes::CAPTURE_UNUSABLE_COVERAGE,
|
|
];
|
|
}
|
|
|
|
private function blockedReasonMessage(string $reasonCode, bool $changedAfterEnqueue): string
|
|
{
|
|
return match ($reasonCode) {
|
|
BaselineReasonCodes::CAPTURE_INVENTORY_MISSING => 'The baseline capture could not continue because no current inventory basis was available.',
|
|
BaselineReasonCodes::CAPTURE_INVENTORY_BLOCKED => $changedAfterEnqueue
|
|
? 'The baseline capture stopped because the latest inventory sync changed after the run was queued.'
|
|
: 'The baseline capture was blocked because the latest inventory sync was blocked.',
|
|
BaselineReasonCodes::CAPTURE_INVENTORY_FAILED => $changedAfterEnqueue
|
|
? 'The baseline capture stopped because the latest inventory sync failed after the run was queued.'
|
|
: 'The baseline capture was blocked because the latest inventory sync failed.',
|
|
BaselineReasonCodes::CAPTURE_UNUSABLE_COVERAGE => $changedAfterEnqueue
|
|
? 'The baseline capture stopped because the latest inventory coverage became unusable after the run was queued.'
|
|
: 'The baseline capture could not produce a usable baseline because the latest inventory coverage was not credible.',
|
|
default => 'The baseline capture was blocked before it could produce trustworthy snapshot proof.',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @return array<string, int>
|
|
*/
|
|
private function summaryCounts(int $subjectsTotal, int $itemsCaptured): array
|
|
{
|
|
$total = max(0, $subjectsTotal);
|
|
$succeeded = max(0, $itemsCaptured);
|
|
$total = max($total, $succeeded);
|
|
|
|
return [
|
|
'total' => $total,
|
|
'processed' => $total,
|
|
'succeeded' => $succeeded,
|
|
'failed' => max(0, $total - $succeeded),
|
|
];
|
|
}
|
|
|
|
private function intValue(mixed $value): int
|
|
{
|
|
return is_numeric($value) ? (int) $value : 0;
|
|
}
|
|
}
|