instances added god may help

This commit is contained in:
2026-02-23 12:58:19 +01:00
parent 25370a1a55
commit 99db252f60
290 changed files with 9858 additions and 4914 deletions

View File

@@ -14,15 +14,25 @@ use MintyPHP\Repository\Support\RepoQuery;
* Acquires a MySQL advisory lock before any work to ensure only one runner
* process is active at a time (safe for minute-by-minute cron invocations).
* Individual jobs are dispatched via ScheduledJobRegistry, and every execution
* attempt including skips and failures is written to the run log.
* attempt - including skips and failures - is written to the run log.
*/
class SchedulerRunService
{
private const RUNNER_LOCK_NAME = 'scheduled_jobs_runner';
public static function runDueJobs(int $limit = 20): array
public function __construct(
private readonly ScheduledJobService $scheduledJobService,
private readonly ScheduledJobRepository $scheduledJobRepository,
private readonly ScheduledJobRunRepository $scheduledJobRunRepository,
private readonly SchedulerRuntimeRepository $schedulerRuntimeRepository,
private readonly ScheduledJobRegistry $scheduledJobRegistry,
private readonly ScheduleCalculator $scheduleCalculator
) {
}
public function runDueJobs(int $limit = 20): array
{
ScheduledJobService::ensureSystemJobs();
$this->scheduledJobService->ensureSystemJobs();
$startedAt = microtime(true);
$result = [
'ok' => true,
@@ -34,19 +44,19 @@ class SchedulerRunService
'skipped' => 0,
];
if (!self::acquireRunnerLock()) {
if (!$this->acquireRunnerLock()) {
$result['ok'] = false;
$result['error'] = 'lock_not_acquired';
$result['duration_ms'] = self::durationMs($startedAt);
self::updateRuntimeHeartbeat('lock_not_acquired', '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 = ScheduledJobRepository::listDueJobs($nowUtc, $limit);
$dueJobs = $this->scheduledJobRepository->listDueJobs($nowUtc, $limit);
foreach ($dueJobs as $job) {
$run = self::runOne($job, 'scheduler', null, false);
$run = $this->runOne($job, 'scheduler', null, false);
$result['processed']++;
$status = (string) ($run['status'] ?? 'failed');
if ($status === 'success') {
@@ -61,46 +71,46 @@ class SchedulerRunService
$result['ok'] = false;
$result['error'] = 'unexpected_error';
} finally {
self::releaseRunnerLock();
$result['duration_ms'] = self::durationMs($startedAt);
$this->releaseRunnerLock();
$result['duration_ms'] = $this->durationMs($startedAt);
if ($result['ok']) {
self::updateRuntimeHeartbeat('ok', null);
$this->updateRuntimeHeartbeat('ok', null);
} else {
$errorCode = self::nullableString($result['error'] ?? null) ?? 'unexpected_error';
self::updateRuntimeHeartbeat('unexpected_error', $errorCode);
$errorCode = $this->nullableString($result['error']) ?? 'unexpected_error';
$this->updateRuntimeHeartbeat('unexpected_error', $errorCode);
}
}
return $result;
}
public static function runJobNow(int $jobId, int $actorUserId): array
public function runJobNow(int $jobId, int $actorUserId): array
{
ScheduledJobService::ensureSystemJobs();
$this->scheduledJobService->ensureSystemJobs();
if ($jobId <= 0 || $actorUserId <= 0) {
return ['ok' => false, 'error' => 'invalid_request'];
}
if (!self::acquireRunnerLock()) {
if (!$this->acquireRunnerLock()) {
return ['ok' => false, 'error' => 'lock_not_acquired'];
}
try {
$job = ScheduledJobRepository::find($jobId);
$job = $this->scheduledJobRepository->find($jobId);
if (!$job) {
return ['ok' => false, 'error' => 'job_not_found'];
}
$run = self::runOne($job, 'manual', $actorUserId, true);
$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 {
self::releaseRunnerLock();
$this->releaseRunnerLock();
}
}
private static function runOne(array $job, string $triggerType, ?int $actorUserId, bool $force): array
private function runOne(array $job, string $triggerType, ?int $actorUserId, bool $force): array
{
$jobId = (int) ($job['id'] ?? 0);
$nowUtc = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
@@ -115,9 +125,9 @@ class SchedulerRunService
return ['status' => 'skipped', 'error_code' => 'job_disabled', 'error_message' => null];
}
$markedRunning = ScheduledJobRepository::markRunning($jobId, $startedAtUtc);
$markedRunning = $this->scheduledJobRepository->markRunning($jobId, $startedAtUtc);
if (!$markedRunning) {
self::insertRunLog([
$this->insertRunLog([
'job_id' => $jobId,
'job_key' => (string) ($job['job_key'] ?? ''),
'trigger_type' => $triggerType,
@@ -140,17 +150,17 @@ class SchedulerRunService
$resultPayload = [];
try {
$definition = ScheduledJobRegistry::get((string) ($job['job_key'] ?? ''));
$definition = $this->scheduledJobRegistry->get((string) ($job['job_key'] ?? ''));
if (!$definition) {
$status = 'failed';
$errorCode = 'job_not_registered';
} else {
$execution = ScheduledJobRegistry::execute((string) $job['job_key'], $actorUserId);
$execution = $this->scheduledJobRegistry->execute((string) $job['job_key'], $actorUserId);
$status = in_array((string) $execution['status'], ['success', 'failed', 'skipped'], true)
? (string) $execution['status']
: 'failed';
$errorCode = self::nullableString($execution['error_code']);
$errorMessage = self::nullableString($execution['error_message']);
$errorCode = $this->nullableString($execution['error_code']);
$errorMessage = $this->nullableString($execution['error_message']);
$resultPayload = $execution['result'];
}
} catch (\Throwable $exception) {
@@ -168,14 +178,14 @@ class SchedulerRunService
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.
// 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 = self::parseUtc((string) $job['next_run_at']) ?? $finishedAt;
$next = ScheduleCalculator::calculateNextRunUtc($job, $reference);
$reference = $this->parseUtc((string) $job['next_run_at']) ?? $finishedAt;
$next = $this->scheduleCalculator->calculateNextRunUtc($job, $reference);
$guard = 0;
while ($next && $next <= $finishedAt && $guard < 500) {
$next = ScheduleCalculator::calculateNextRunUtc($job, $next);
$next = $this->scheduleCalculator->calculateNextRunUtc($job, $next);
$guard++;
}
$nextRunUtc = $next ? $next->format('Y-m-d H:i:s') : null;
@@ -183,12 +193,12 @@ class SchedulerRunService
// 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 = ScheduleCalculator::calculateNextRunUtc($job, $finishedAt);
$next = $this->scheduleCalculator->calculateNextRunUtc($job, $finishedAt);
$nextRunUtc = $next ? $next->format('Y-m-d H:i:s') : null;
}
}
ScheduledJobRepository::finishRun(
$this->scheduledJobRepository->finishRun(
$jobId,
$status,
$startedAtUtc,
@@ -198,7 +208,7 @@ class SchedulerRunService
$errorMessage
);
self::insertRunLog([
$this->insertRunLog([
'job_id' => $jobId,
'job_key' => (string) ($job['job_key'] ?? ''),
'trigger_type' => $triggerType,
@@ -209,16 +219,16 @@ class SchedulerRunService
'duration_ms' => $durationMs,
'error_code' => $errorCode,
'error_message' => $errorMessage,
'result_json' => self::encodeResult($resultPayload),
'result_json' => $this->encodeResult($resultPayload),
]);
return ['status' => $status, 'error_code' => $errorCode, 'error_message' => $errorMessage];
}
private static function insertRunLog(array $data): void
private function insertRunLog(array $data): void
{
try {
ScheduledJobRunRepository::create([
$this->scheduledJobRunRepository->create([
'run_uuid' => RepoQuery::uuidV4(),
'job_id' => (int) ($data['job_id'] ?? 0),
'job_key' => (string) ($data['job_key'] ?? ''),
@@ -237,7 +247,7 @@ class SchedulerRunService
}
}
private static function encodeResult(array $result): ?string
private function encodeResult(array $result): ?string
{
if (!$result) {
return null;
@@ -246,7 +256,7 @@ class SchedulerRunService
return is_string($encoded) ? $encoded : null;
}
private static function parseUtc(string $value): ?\DateTimeImmutable
private function parseUtc(string $value): ?\DateTimeImmutable
{
$value = trim($value);
if ($value === '') {
@@ -259,28 +269,28 @@ class SchedulerRunService
}
}
private static function nullableString(mixed $value): ?string
private function nullableString(mixed $value): ?string
{
$value = trim((string) $value);
return $value !== '' ? $value : null;
}
private static function acquireRunnerLock(): bool
private function acquireRunnerLock(): bool
{
$got = DB::selectValue('select GET_LOCK(?, 0) as got_lock', self::RUNNER_LOCK_NAME);
return (int) $got === 1;
}
private static function updateRuntimeHeartbeat(string $status, ?string $errorCode): void
private function updateRuntimeHeartbeat(string $status, ?string $errorCode): void
{
try {
SchedulerRuntimeRepository::touchHeartbeat($status, $errorCode);
$this->schedulerRuntimeRepository->touchHeartbeat($status, $errorCode);
} catch (\Throwable $exception) {
// fail-open
}
}
private static function releaseRunnerLock(): void
private function releaseRunnerLock(): void
{
try {
DB::selectValue('select RELEASE_LOCK(?) as released_lock', self::RUNNER_LOCK_NAME);
@@ -289,7 +299,7 @@ class SchedulerRunService
}
}
private static function durationMs(float $startedAt): int
private function durationMs(float $startedAt): int
{
return (int) max(0, round((microtime(true) - $startedAt) * 1000));
}