TenantAtlas/apps/platform/app/Services/AdapterRunReconciler.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

237 lines
7.3 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\OperationRun;
use App\Support\OperationCatalog;
use App\Support\OperationRunStatus;
use App\Support\Operations\Reconciliation\OperationRunReconciliationAdapter;
use App\Support\Operations\Reconciliation\OperationRunReconciliationRegistry;
use App\Support\Operations\Reconciliation\ReconciliationResult;
use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Builder;
use Throwable;
final class AdapterRunReconciler
{
public function __construct(
private readonly OperationRunReconciliationRegistry $registry,
private readonly OperationRunService $operationRuns,
) {}
/**
* @return array<int, string>
*/
public function supportedTypes(): array
{
return $this->registry->supportedTypes();
}
/**
* @param array{type?: string|null, managed_environment_id?: int|null, older_than_minutes?: int, limit?: int, dry_run?: bool} $options
* @return array{candidates:int,reconciled:int,skipped:int,changes:array<int,array<string,mixed>>}
*/
public function reconcile(array $options = []): array
{
$type = $this->normalizeRequestedType($options['type'] ?? null);
$tenantId = $options['managed_environment_id'] ?? null;
$olderThanMinutes = max(1, (int) ($options['older_than_minutes'] ?? 10));
$limit = max(1, (int) ($options['limit'] ?? 50));
$dryRun = (bool) ($options['dry_run'] ?? true);
$cutoff = CarbonImmutable::now()->subMinutes($olderThanMinutes);
$candidateTypes = $this->candidateRawTypes($type);
$query = OperationRun::query()
->whereIn('type', $candidateTypes)
->whereIn('status', [OperationRunStatus::Queued->value, OperationRunStatus::Running->value])
->where(function (Builder $q) use ($cutoff): void {
$q
->where(function (Builder $q2) use ($cutoff): void {
$q2->whereNull('started_at')->where('created_at', '<', $cutoff);
})
->orWhere(function (Builder $q2) use ($cutoff): void {
$q2->whereNotNull('started_at')->where('started_at', '<', $cutoff);
});
})
->orderBy('id')
->limit($limit);
if (is_int($tenantId) && $tenantId > 0) {
$query->where('managed_environment_id', $tenantId);
}
$candidates = $query->get();
$changes = [];
$reconciled = 0;
$skipped = 0;
foreach ($candidates as $run) {
$change = $this->reconcileOperationRun($run, $dryRun);
if ($change === null) {
$skipped++;
continue;
}
$changes[] = $change;
if (($change['applied'] ?? false) === true) {
$reconciled++;
}
}
return [
'candidates' => $candidates->count(),
'reconciled' => $reconciled,
'skipped' => $skipped,
'changes' => $changes,
];
}
/**
* @return array<string, mixed>|null
*/
public function reconcileOperationRun(OperationRun $run, bool $dryRun = false): ?array
{
$adapter = $this->resolveAdapter($run);
return $this->reconcileUsingAdapter(
run: $run,
result: $adapter?->reconcile($run),
adapter: $adapter,
dryRun: $dryRun,
);
}
/**
* @return array<string, mixed>|null
*/
public function reconcileOperationRunFailure(OperationRun $run, Throwable $throwable, bool $dryRun = false): ?array
{
$adapter = $this->resolveAdapter($run);
return $this->reconcileUsingAdapter(
run: $run,
result: $adapter?->reconcileException($run, $throwable),
adapter: $adapter,
dryRun: $dryRun,
);
}
/**
* @return array<string, mixed>|null
*/
private function reconcileUsingAdapter(
OperationRun $run,
?ReconciliationResult $result,
?OperationRunReconciliationAdapter $adapter,
bool $dryRun,
): ?array {
if (! $adapter instanceof OperationRunReconciliationAdapter || ! $result instanceof ReconciliationResult) {
return null;
}
$before = [
'status' => (string) $run->status,
'outcome' => (string) $run->outcome,
];
$after = $result->shouldFinalizeRun()
? [
'status' => (string) $result->status,
'outcome' => (string) $result->outcome,
]
: null;
if ($dryRun) {
return array_merge([
'applied' => false,
'operation_run_id' => (int) $run->getKey(),
'type' => (string) $run->type,
'adapter' => $adapter->key(),
'before' => $before,
], $result->toArray(), $after !== null ? ['after' => $after] : []);
}
if (! $result->shouldFinalizeRun()) {
return null;
}
$this->operationRuns->applyReconciliationResult(
run: $run,
result: $result,
source: 'adapter_reconciler',
adapter: $adapter->key(),
);
$run->refresh();
$this->syncRestoreLifecycleTimestamps($run, $result);
return array_merge([
'applied' => true,
'operation_run_id' => (int) $run->getKey(),
'type' => (string) $run->type,
'adapter' => $adapter->key(),
'before' => $before,
'after' => $after ?? [],
], $result->toArray());
}
private function resolveAdapter(OperationRun $run): ?OperationRunReconciliationAdapter
{
return $this->registry->forType($run->canonicalOperationType());
}
/**
* @return array<int, string>
*/
private function candidateRawTypes(?string $type = null): array
{
$canonicalTypes = $type !== null ? [$type] : $this->supportedTypes();
return array_values(array_unique(array_merge(
...array_map(
static fn (string $canonicalType): array => OperationCatalog::rawValuesForCanonical($canonicalType),
$canonicalTypes,
),
)));
}
private function normalizeRequestedType(mixed $type): ?string
{
if (! is_string($type) || trim($type) === '') {
return null;
}
$canonicalType = OperationCatalog::canonicalCode($type);
if (! in_array($canonicalType, $this->supportedTypes(), true)) {
throw new \InvalidArgumentException('Unsupported adapter run type: '.$type);
}
return $canonicalType;
}
private function syncRestoreLifecycleTimestamps(OperationRun $run, ReconciliationResult $result): void
{
if (! array_key_exists('restore_run_id', $result->evidence)) {
return;
}
if ($run->started_at === null && is_string($result->evidence['restore_started_at'] ?? null)) {
$run->started_at = $result->evidence['restore_started_at'];
}
if ($run->completed_at === null && is_string($result->evidence['restore_completed_at'] ?? null)) {
$run->completed_at = $result->evidence['restore_completed_at'];
}
$run->save();
}
}