scheduledJobService->ensureSystemJobs(); $startedAt = microtime(true); $result = [ 'ok' => true, 'error' => null, 'duration_ms' => 0, 'processed' => 0, 'success' => 0, 'failed' => 0, 'skipped' => 0, ]; if (!$this->acquireRunnerLock()) { $result['ok'] = false; $result['error'] = 'lock_not_acquired'; $result['duration_ms'] = $this->durationMs($startedAt); $this->updateRuntimeHeartbeat('lock_not_acquired', 'lock_not_acquired'); return $result; } try { $nowUtc = gmdate('Y-m-d H:i:s'); $dueJobs = $this->scheduledJobRepository->listDueJobs($nowUtc, $limit); foreach ($dueJobs as $job) { $run = $this->runOne($job, 'scheduler', null, false); $result['processed']++; $status = (string) ($run['status'] ?? 'failed'); if ($status === 'success') { $result['success']++; } elseif ($status === 'skipped') { $result['skipped']++; } else { $result['failed']++; } } } catch (\Throwable $exception) { $result['ok'] = false; $result['error'] = 'unexpected_error'; } finally { $this->releaseRunnerLock(); $result['duration_ms'] = $this->durationMs($startedAt); if ($result['ok']) { $this->updateRuntimeHeartbeat('ok', null); } else { $errorCode = $this->nullableString($result['error']) ?? 'unexpected_error'; $this->updateRuntimeHeartbeat('unexpected_error', $errorCode); } } return $result; } public function runJobNow(int $jobId, int $actorUserId): array { $this->scheduledJobService->ensureSystemJobs(); if ($jobId <= 0 || $actorUserId <= 0) { return ['ok' => false, 'error' => 'invalid_request']; } if (!$this->acquireRunnerLock()) { return ['ok' => false, 'error' => 'lock_not_acquired']; } try { $job = $this->scheduledJobRepository->find($jobId); if (!$job) { return ['ok' => false, 'error' => 'job_not_found']; } $run = $this->runOne($job, 'manual', $actorUserId, true); return [ 'ok' => in_array((string) ($run['status'] ?? ''), ['success', 'skipped'], true), 'status' => (string) ($run['status'] ?? 'failed'), 'error' => (string) ($run['error_code'] ?? ''), ]; } finally { $this->releaseRunnerLock(); } } private function runOne(array $job, string $triggerType, ?int $actorUserId, bool $force): array { $jobId = (int) ($job['id'] ?? 0); $nowUtc = new \DateTimeImmutable('now', new \DateTimeZone('UTC')); $startedAtUtc = $nowUtc->format('Y-m-d H:i:s'); $triggerType = in_array($triggerType, ['scheduler', 'manual'], true) ? $triggerType : 'scheduler'; if ($jobId <= 0) { return ['status' => 'failed', 'error_code' => 'job_not_found', 'error_message' => null]; } if (!$force && ((int) ($job['enabled'] ?? 0) !== 1 || empty($job['next_run_at']))) { return ['status' => 'skipped', 'error_code' => 'job_disabled', 'error_message' => null]; } $markedRunning = $this->scheduledJobRepository->markRunning($jobId, $startedAtUtc); if (!$markedRunning) { $this->insertRunLog([ 'job_id' => $jobId, 'job_key' => (string) ($job['job_key'] ?? ''), 'trigger_type' => $triggerType, 'status' => 'skipped', 'actor_user_id' => $actorUserId, 'started_at' => $startedAtUtc, 'finished_at' => $startedAtUtc, 'duration_ms' => 0, 'error_code' => 'job_already_running', 'error_message' => null, 'result_json' => null, ]); return ['status' => 'skipped', 'error_code' => 'job_already_running', 'error_message' => null]; } $startedMs = microtime(true); $status = 'failed'; $errorCode = null; $errorMessage = null; $resultPayload = []; try { $definition = $this->scheduledJobRegistry->get((string) ($job['job_key'] ?? '')); if (!$definition) { $status = 'failed'; $errorCode = 'job_not_registered'; } else { $execution = $this->scheduledJobRegistry->execute((string) $job['job_key'], $actorUserId); $status = in_array((string) $execution['status'], ['success', 'failed', 'skipped'], true) ? (string) $execution['status'] : 'failed'; $errorCode = $this->nullableString($execution['error_code']); $errorMessage = $this->nullableString($execution['error_message']); $resultPayload = $execution['result']; } } catch (\Throwable $exception) { $status = 'failed'; $errorCode = 'unexpected_error'; $errorMessage = substr($exception->getMessage(), 0, 255) ?: null; } $finishedAt = new \DateTimeImmutable('now', new \DateTimeZone('UTC')); $finishedAtUtc = $finishedAt->format('Y-m-d H:i:s'); $durationMs = max(0, (int) round((microtime(true) - $startedMs) * 1000)); $nextRunUtc = null; if ((int) ($job['enabled'] ?? 0) === 1) { if ($triggerType === 'scheduler' && (int) ($job['catchup_once'] ?? 1) === 0 && !empty($job['next_run_at'])) { // catchup_once = 0: advance next_run_at strictly from the originally scheduled // time, skipping any windows that already passed. This prevents a backlog storm // after downtime - missed slots are dropped, only the next future slot is kept. // The guard cap (500) prevents an infinite loop if calculateNextRunUtc has a bug // and keeps returning a time that is still in the past. $reference = $this->parseUtc((string) $job['next_run_at']) ?? $finishedAt; $next = $this->scheduleCalculator->calculateNextRunUtc($job, $reference); $guard = 0; while ($next && $next <= $finishedAt && $guard < 500) { $next = $this->scheduleCalculator->calculateNextRunUtc($job, $next); $guard++; } $nextRunUtc = $next ? $next->format('Y-m-d H:i:s') : null; } else { // catchup_once = 1 (default): calculate next_run_at from the actual finish time. // If a run was delayed or a slot was missed, exactly one catch-up run is scheduled // starting from now, then normal scheduling resumes. $next = $this->scheduleCalculator->calculateNextRunUtc($job, $finishedAt); $nextRunUtc = $next ? $next->format('Y-m-d H:i:s') : null; } } $this->scheduledJobRepository->finishRun( $jobId, $status, $startedAtUtc, $finishedAtUtc, $nextRunUtc, $errorCode, $errorMessage ); $this->insertRunLog([ 'job_id' => $jobId, 'job_key' => (string) ($job['job_key'] ?? ''), 'trigger_type' => $triggerType, 'status' => $status, 'actor_user_id' => $actorUserId, 'started_at' => $startedAtUtc, 'finished_at' => $finishedAtUtc, 'duration_ms' => $durationMs, 'error_code' => $errorCode, 'error_message' => $errorMessage, 'result_json' => $this->encodeResult($resultPayload), ]); return ['status' => $status, 'error_code' => $errorCode, 'error_message' => $errorMessage]; } private function insertRunLog(array $data): void { try { $this->scheduledJobRunRepository->create([ 'run_uuid' => RepoQuery::uuidV4(), 'job_id' => (int) ($data['job_id'] ?? 0), 'job_key' => (string) ($data['job_key'] ?? ''), 'trigger_type' => (string) ($data['trigger_type'] ?? 'scheduler'), 'status' => (string) ($data['status'] ?? 'failed'), 'actor_user_id' => ($data['actor_user_id'] ?? null) !== null ? (int) $data['actor_user_id'] : null, 'started_at' => (string) ($data['started_at'] ?? ''), 'finished_at' => $data['finished_at'] ?? null, 'duration_ms' => ($data['duration_ms'] ?? null) !== null ? (int) $data['duration_ms'] : null, 'error_code' => $data['error_code'] ?? null, 'error_message' => $data['error_message'] ?? null, 'result_json' => $data['result_json'] ?? null, ]); } catch (\Throwable $exception) { // fail-open } } private function encodeResult(array $result): ?string { if (!$result) { return null; } $encoded = json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); return is_string($encoded) ? $encoded : null; } private function parseUtc(string $value): ?\DateTimeImmutable { $value = trim($value); if ($value === '') { return null; } try { return new \DateTimeImmutable($value, new \DateTimeZone('UTC')); } catch (\Throwable $exception) { return null; } } private function nullableString(mixed $value): ?string { $value = trim((string) $value); return $value !== '' ? $value : null; } private function acquireRunnerLock(): bool { $got = DB::selectValue('select GET_LOCK(?, 0) as got_lock', self::RUNNER_LOCK_NAME); return (int) $got === 1; } private function updateRuntimeHeartbeat(string $status, ?string $errorCode): void { try { $this->schedulerRuntimeRepository->touchHeartbeat($status, $errorCode); } catch (\Throwable $exception) { // fail-open } } private function releaseRunnerLock(): void { try { DB::selectValue('select RELEASE_LOCK(?) as released_lock', self::RUNNER_LOCK_NAME); } catch (\Throwable $exception) { // no-op } } private function durationMs(float $startedAt): int { return (int) max(0, round((microtime(true) - $startedAt) * 1000)); } }