TenantAtlas/app/Services/AdapterRunReconciler.php
ahmido a97beefda3 056-remove-legacy-bulkops (#65)
Kurzbeschreibung

Versteckt die Rerun-Row-Action für archivierte (soft-deleted) RestoreRuns und verhindert damit fehlerhafte Neu-Starts aus dem Archiv; ergänzt einen Regressionstest.
Änderungen

Code: RestoreRunResource.php — Sichtbarkeit der rerun-Action geprüft auf ! $record->trashed() und defensive Abbruchprüfung im Action-Handler.
Tests: RestoreRunRerunTest.php — neuer Test rerun action is hidden for archived restore runs.
Warum

Archivierte RestoreRuns durften nicht neu gestartet werden; UI zeigte trotzdem die Option. Das führte zu verwirrendem Verhalten und möglichen Fehlern beim Enqueueing.
Verifikation / QA

Unit/Feature:
./vendor/bin/sail artisan test tests/Feature/RestoreRunRerunTest.php
Stil/format:
./vendor/bin/pint --dirty
Manuell (UI):
Als Tenant-Admin Filament → Restore Runs öffnen.
Filter Archived aktivieren (oder Trashed filter auswählen).
Sicherstellen, dass für archivierte Einträge die Rerun-Action nicht sichtbar ist.
Auf einem aktiven (nicht-archivierten) Run prüfen, dass Rerun sichtbar bleibt und wie erwartet eine neue RestoreRun erzeugt.
Wichtige Hinweise

Kein DB-Migration required.
Diese PR enthält nur den UI-/Filament-Fix; die zuvor gemachten operative Fixes für Queue/adapter-Reconciliation bleiben ebenfalls auf dem Branch (z. B. frühere commits während der Debugging-Session).
T055 (Schema squash) wurde bewusst zurückgestellt und ist nicht Teil dieses PRs.
Merge-Checklist

 Tests lokal laufen (RestoreRunRerunTest grünt)
 Pint läuft ohne ungepatchte Fehler
 Branch gepusht: 056-remove-legacy-bulkops (PR-URL: https://git.cloudarix.de/ahmido/TenantAtlas/compare/dev...056-remove-legacy-bulkops)

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local>
Reviewed-on: #65
2026-01-19 23:27:52 +00:00

274 lines
8.9 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\OperationRun;
use App\Models\RestoreRun;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\RestoreRunStatus;
use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Builder;
final class AdapterRunReconciler
{
/**
* @return array<int, string>
*/
public function supportedTypes(): array
{
return [
'restore.execute',
];
}
/**
* @param array{type?: string|null, tenant_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 = $options['type'] ?? null;
$tenantId = $options['tenant_id'] ?? null;
$olderThanMinutes = max(1, (int) ($options['older_than_minutes'] ?? 10));
$limit = max(1, (int) ($options['limit'] ?? 50));
$dryRun = (bool) ($options['dry_run'] ?? true);
if ($type !== null && ! in_array($type, $this->supportedTypes(), true)) {
throw new \InvalidArgumentException('Unsupported adapter run type: '.$type);
}
$cutoff = CarbonImmutable::now()->subMinutes($olderThanMinutes);
$query = OperationRun::query()
->whereIn('type', $type ? [$type] : $this->supportedTypes())
->whereIn('status', [OperationRunStatus::Queued->value, OperationRunStatus::Running->value])
->whereNotNull('context->restore_run_id')
->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('tenant_id', $tenantId);
}
$candidates = $query->get();
$changes = [];
$reconciled = 0;
$skipped = 0;
foreach ($candidates as $run) {
$change = $this->reconcileOne($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
*/
private function reconcileOne(OperationRun $run, bool $dryRun): ?array
{
if ($run->type !== 'restore.execute') {
return null;
}
$context = is_array($run->context) ? $run->context : [];
$restoreRunId = $context['restore_run_id'] ?? null;
if (! is_numeric($restoreRunId)) {
return null;
}
$restoreRun = RestoreRun::query()
->where('tenant_id', $run->tenant_id)
->whereKey((int) $restoreRunId)
->first();
if (! $restoreRun instanceof RestoreRun) {
return null;
}
$restoreStatus = RestoreRunStatus::fromString($restoreRun->status);
if (! $this->isTerminalRestoreStatus($restoreStatus)) {
return null;
}
[$opStatus, $opOutcome, $failures] = $this->mapRestoreToOperationRun($restoreRun, $restoreStatus);
$summaryCounts = $this->buildSummaryCounts($restoreRun);
$before = [
'status' => (string) $run->status,
'outcome' => (string) $run->outcome,
];
$after = [
'status' => $opStatus,
'outcome' => $opOutcome,
];
if ($dryRun) {
return [
'applied' => false,
'operation_run_id' => (int) $run->getKey(),
'type' => (string) $run->type,
'restore_run_id' => (int) $restoreRun->getKey(),
'before' => $before,
'after' => $after,
];
}
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$runs->updateRun(
$run,
status: $opStatus,
outcome: $opOutcome,
summaryCounts: $summaryCounts,
failures: $failures,
);
$run->refresh();
$updatedContext = is_array($run->context) ? $run->context : [];
$reconciliation = is_array($updatedContext['reconciliation'] ?? null) ? $updatedContext['reconciliation'] : [];
$reconciliation['reconciled_at'] = CarbonImmutable::now()->toIso8601String();
$reconciliation['reason'] = 'adapter_out_of_sync';
$updatedContext['reconciliation'] = $reconciliation;
$run->context = $updatedContext;
if ($run->started_at === null && $restoreRun->started_at !== null) {
$run->started_at = $restoreRun->started_at;
}
if ($run->completed_at === null && $restoreRun->completed_at !== null) {
$run->completed_at = $restoreRun->completed_at;
}
$run->save();
return [
'applied' => true,
'operation_run_id' => (int) $run->getKey(),
'type' => (string) $run->type,
'restore_run_id' => (int) $restoreRun->getKey(),
'before' => $before,
'after' => $after,
];
}
private function isTerminalRestoreStatus(?RestoreRunStatus $status): bool
{
if (! $status instanceof RestoreRunStatus) {
return false;
}
return in_array($status, [
RestoreRunStatus::Previewed,
RestoreRunStatus::Completed,
RestoreRunStatus::Partial,
RestoreRunStatus::Failed,
RestoreRunStatus::Cancelled,
RestoreRunStatus::Aborted,
RestoreRunStatus::CompletedWithErrors,
], true);
}
/**
* @return array{0:string,1:string,2:array<int,array{code:string,message:string}>}
*/
private function mapRestoreToOperationRun(RestoreRun $restoreRun, RestoreRunStatus $status): array
{
$failureReason = is_string($restoreRun->failure_reason ?? null) ? (string) $restoreRun->failure_reason : '';
return match ($status) {
RestoreRunStatus::Previewed => [OperationRunStatus::Completed->value, OperationRunOutcome::Succeeded->value, []],
RestoreRunStatus::Completed => [OperationRunStatus::Completed->value, OperationRunOutcome::Succeeded->value, []],
RestoreRunStatus::Partial, RestoreRunStatus::CompletedWithErrors => [
OperationRunStatus::Completed->value,
OperationRunOutcome::PartiallySucceeded->value,
[[
'code' => 'restore.completed_with_warnings',
'message' => $failureReason !== '' ? $failureReason : 'Restore completed with warnings.',
]],
],
RestoreRunStatus::Failed, RestoreRunStatus::Aborted => [
OperationRunStatus::Completed->value,
OperationRunOutcome::Failed->value,
[[
'code' => 'restore.failed',
'message' => $failureReason !== '' ? $failureReason : 'Restore failed.',
]],
],
RestoreRunStatus::Cancelled => [
OperationRunStatus::Completed->value,
OperationRunOutcome::Failed->value,
[[
'code' => 'restore.cancelled',
'message' => 'Restore run was cancelled.',
]],
],
default => [OperationRunStatus::Running->value, OperationRunOutcome::Pending->value, []],
};
}
/**
* @return array<string, int>
*/
private function buildSummaryCounts(RestoreRun $restoreRun): array
{
$metadata = is_array($restoreRun->metadata) ? $restoreRun->metadata : [];
$counts = [];
foreach (['total', 'processed', 'succeeded', 'failed', 'skipped'] as $key) {
if (array_key_exists($key, $metadata) && is_numeric($metadata[$key])) {
$counts[$key] = (int) $metadata[$key];
}
}
if (! isset($counts['processed'])) {
$processed = (int) ($counts['succeeded'] ?? 0) + (int) ($counts['failed'] ?? 0) + (int) ($counts['skipped'] ?? 0);
if ($processed > 0) {
$counts['processed'] = $processed;
}
}
return $counts;
}
}