TenantAtlas/app/Jobs/ReconcileAdapterRunsJob.php
ahmido 03127a670b Spec 096: Ops polish (assignment summaries + dedupe + reconcile tracking + seed DX) (#115)
Implements Spec 096 ops polish bundle:

- Persist durable OperationRun.summary_counts for assignment fetch/restore (final attempt wins)
- Server-side dedupe for assignment jobs (15-minute cooldown + non-canonical skip)
- Track ReconcileAdapterRunsJob via workspace-scoped OperationRun + stable failure codes + overlap prevention
- Seed DX: ensure seeded tenants use UUID v4 external_id and seed satisfies workspace_id NOT NULL constraints

Verification (local / evidence-based):
- `vendor/bin/sail artisan test --compact tests/Feature/Operations/AssignmentRunSummaryCountsTest.php tests/Feature/Operations/AssignmentJobDedupeTest.php tests/Feature/Operations/ReconcileAdapterRunsJobTrackingTest.php tests/Feature/Seed/PoliciesSeederExternalIdTest.php`
- `vendor/bin/sail bin pint --dirty`

Spec artifacts included under `specs/096-ops-polish-assignment-dedupe-system-tracking/` (spec/plan/tasks/checklists).

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #115
2026-02-15 20:49:38 +00:00

155 lines
4.8 KiB
PHP

<?php
namespace App\Jobs;
use App\Models\Workspace;
use App\Services\AdapterRunReconciler;
use App\Services\OperationRunService;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OpsUx\RunFailureSanitizer;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Support\Facades\Log;
use Throwable;
class ReconcileAdapterRunsJob implements ShouldQueue
{
use Queueable;
private const string OPERATION_TYPE = 'ops.reconcile_adapter_runs';
private const int LOCK_TTL_SECONDS = 900;
/**
* Create a new job instance.
*/
public function __construct(
public ?int $workspaceId = null,
public ?string $slotKey = null,
) {
$this->slotKey = $slotKey ?: now()->startOfMinute()->toDateTimeString();
}
/**
* @return array<int, object>
*/
public function middleware(): array
{
return [
(new WithoutOverlapping(self::OPERATION_TYPE))
->expireAfter(self::LOCK_TTL_SECONDS)
->dontRelease(),
];
}
/**
* Execute the job.
*/
public function handle(
AdapterRunReconciler $reconciler,
OperationRunService $operationRunService,
): void {
$workspace = $this->resolveWorkspace();
if (! $workspace instanceof Workspace) {
Log::warning('ReconcileAdapterRunsJob skipped because no workspace was found.');
return;
}
$operationRun = $operationRunService->ensureWorkspaceRunWithIdentity(
workspace: $workspace,
type: self::OPERATION_TYPE,
identityInputs: [
'job' => self::OPERATION_TYPE,
'slot' => (string) $this->slotKey,
],
context: [
'job' => self::OPERATION_TYPE,
'slot' => (string) $this->slotKey,
'trigger' => 'schedule',
],
);
if ($operationRun->status !== OperationRunStatus::Completed->value) {
$operationRunService->updateRun($operationRun, OperationRunStatus::Running->value);
}
try {
$result = $this->reconcile($reconciler);
$candidates = (int) ($result['candidates'] ?? 0);
$reconciled = (int) ($result['reconciled'] ?? 0);
$skipped = (int) ($result['skipped'] ?? 0);
$operationRunService->updateRun(
$operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Succeeded->value,
summaryCounts: [
'total' => $candidates,
'processed' => $reconciled + $skipped,
'failed' => 0,
],
);
Log::info('ReconcileAdapterRunsJob completed', [
'operation_run_id' => (int) $operationRun->getKey(),
'candidates' => $candidates,
'reconciled' => $reconciled,
'skipped' => $skipped,
]);
} catch (Throwable $e) {
$safeMessage = RunFailureSanitizer::sanitizeMessage($e->getMessage());
$operationRunService->updateRun(
$operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Failed->value,
summaryCounts: [
'total' => 0,
'processed' => 0,
'failed' => 1,
],
failures: [[
'code' => 'ops.reconcile_adapter_runs.failed',
'reason_code' => RunFailureSanitizer::normalizeReasonCode($e->getMessage()),
'message' => $safeMessage !== '' ? $safeMessage : 'Adapter run reconciliation failed.',
]],
);
Log::warning('ReconcileAdapterRunsJob failed', [
'operation_run_id' => (int) $operationRun->getKey(),
'error' => $safeMessage !== '' ? $safeMessage : 'Adapter run reconciliation failed.',
]);
throw $e;
}
}
/**
* @return array{candidates:int, reconciled:int, skipped:int}
*/
protected function reconcile(AdapterRunReconciler $reconciler): array
{
return $reconciler->reconcile([
'older_than_minutes' => 60,
'limit' => 50,
'dry_run' => false,
]);
}
private function resolveWorkspace(): ?Workspace
{
if (is_int($this->workspaceId) && $this->workspaceId > 0) {
return Workspace::query()->find($this->workspaceId);
}
return Workspace::query()
->orderBy('id')
->first();
}
}