TenantAtlas/apps/platform/app/Support/Operations/Reconciliation/BackupScheduleExecutionReconciliationAdapter.php
ahmido 548a37c888 feat: implement sync capture backup operation semantics (#433)
Implemented sync capture backup operation semantics as requested.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #433
2026-06-07 01:19:08 +00:00

190 lines
7.8 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Support\Operations\Reconciliation;
use App\Models\BackupSchedule;
use App\Models\BackupSet;
use App\Models\OperationRun;
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 BackupScheduleExecutionReconciliationAdapter implements OperationRunReconciliationAdapter
{
public function key(): string
{
return 'backup_schedule_execution';
}
public function supportedTypes(): array
{
return [OperationRunType::BackupScheduleExecute->value];
}
public function supportsType(string $type): bool
{
return OperationCatalog::canonicalCode($type) === OperationRunType::BackupScheduleExecute->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 backup schedule execution runs.',
evidence: [
'adapter' => $this->key(),
'type' => (string) $run->type,
],
);
}
$context = is_array($run->context) ? $run->context : [];
$scheduleId = is_numeric($context['backup_schedule_id'] ?? null) ? (int) $context['backup_schedule_id'] : null;
$backupSetId = is_numeric($context['backup_set_id'] ?? null) ? (int) $context['backup_set_id'] : null;
$evidence = [
'adapter' => $this->key(),
'operation_run_id' => (int) $run->getKey(),
'workspace_id' => (int) $run->workspace_id,
'managed_environment_id' => (int) $run->managed_environment_id,
'backup_schedule_id' => $scheduleId,
'backup_set_id' => $backupSetId,
];
[$backupSet, $scopeProblem] = $this->resolveBackupSet($run, $backupSetId);
if ($scopeProblem !== null) {
return ReconciliationResult::notReconciled(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: $scopeProblem,
evidence: $evidence,
);
}
if ($backupSet instanceof BackupSet) {
$failures = is_array($backupSet->metadata['failures'] ?? null) ? $backupSet->metadata['failures'] : [];
$failureCount = count($failures);
$itemCount = max(0, (int) ($backupSet->item_count ?? 0));
$summaryCounts = [
'total' => $itemCount + $failureCount,
'processed' => $itemCount + $failureCount,
'succeeded' => $itemCount,
'failed' => $failureCount,
'created' => 1,
'updated' => $itemCount,
'items' => $itemCount + $failureCount,
];
$related = [
'type' => 'backup_set',
'id' => (int) $backupSet->getKey(),
'status' => (string) $backupSet->status,
'item_count' => $itemCount,
];
if ((string) $backupSet->status === 'completed' && $failureCount === 0) {
return ReconciliationResult::reconciledSucceeded(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: 'The backup schedule already produced a complete backup set before the run finished updating.',
evidence: $evidence + ['chosen_backup_set_id' => (int) $backupSet->getKey()],
related: $related,
summaryCounts: $summaryCounts,
);
}
if ($itemCount > 0 && ((string) $backupSet->status === 'partial' || $failureCount > 0)) {
return new ReconciliationResult(
decision: 'reconciled_partially_succeeded',
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::PartiallySucceeded->value,
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: 'The backup schedule produced a usable backup set, but some captures did not finish cleanly.',
evidence: $evidence + ['chosen_backup_set_id' => (int) $backupSet->getKey()],
related: $related,
summaryCounts: $summaryCounts,
failures: [[
'code' => 'backup_schedule.partial',
'reason_code' => LifecycleReconciliationReason::AdapterOutOfSync->value,
'message' => 'The backup schedule produced a usable backup set, but some captures did not finish cleanly.',
]],
safeForAutoCompletion: true,
);
}
if ((string) $backupSet->status === 'failed') {
return ReconciliationResult::failedUnrecoverable(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: 'The backup schedule produced no usable backup set before the run stopped updating.',
evidence: $evidence + ['chosen_backup_set_id' => (int) $backupSet->getKey()],
related: $related,
summaryCounts: $summaryCounts,
);
}
return ReconciliationResult::notReconciled(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: 'The related backup set is still being prepared and does not prove final backup truth yet.',
evidence: $evidence + ['chosen_backup_set_id' => (int) $backupSet->getKey()],
related: $related,
);
}
$schedule = $scheduleId !== null
? BackupSchedule::query()->withTrashed()->whereKey($scheduleId)->where('managed_environment_id', (int) $run->managed_environment_id)->first()
: null;
if ($schedule instanceof BackupSchedule && $schedule->trashed()) {
return ReconciliationResult::blocked(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: 'The backup schedule is archived, so this run never produced a current backup set.',
evidence: $evidence,
summaryCounts: [
'total' => 0,
'processed' => 0,
'succeeded' => 0,
'failed' => 0,
'skipped' => 1,
],
);
}
return ReconciliationResult::notReconciled(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: 'No current backup set proof is available yet for this backup schedule run.',
evidence: $evidence,
);
}
public function reconcileException(OperationRun $run, Throwable $throwable): ?ReconciliationResult
{
return null;
}
/**
* @return array{0:BackupSet|null,1:?string}
*/
private function resolveBackupSet(OperationRun $run, ?int $backupSetId): array
{
if ($backupSetId === null) {
return [null, null];
}
$candidate = BackupSet::query()->whereKey($backupSetId)->first();
if (! $candidate instanceof BackupSet) {
return [null, null];
}
if ((int) $candidate->managed_environment_id !== (int) $run->managed_environment_id) {
return [null, 'The recorded backup set no longer matches the queued backup schedule scope safely.'];
}
return [$candidate, null];
}
}