diff --git a/app/app/Services/Autopodbor/AutopodborStudyScheduler.php b/app/app/Services/Autopodbor/AutopodborStudyScheduler.php new file mode 100644 index 00000000..bc1c9aca --- /dev/null +++ b/app/app/Services/Autopodbor/AutopodborStudyScheduler.php @@ -0,0 +1,154 @@ +table('autopodbor_runs') + ->where('kind', 'study') + ->where('status', 'running') + ->exists(); + if ($busy) { + return null; + } + + // Все queued study-прогоны (по всем тенантам). + $queued = $conn->table('autopodbor_runs') + ->where('kind', 'study') + ->where('status', 'queued') + ->get(['id', 'tenant_id', 'created_at']); + if ($queued->isEmpty()) { + return null; + } + + // last-served по тенанту = MAX(finished_at) среди ВСЕХ его study-прогонов. + // NULL означает «никогда не обслуживался». + $lastServed = $conn->table('autopodbor_runs') + ->where('kind', 'study') + ->groupBy('tenant_id') + ->select('tenant_id', DB::raw('MAX(finished_at) AS last_finished')) + ->pluck('last_finished', 'tenant_id'); + + // (2) выбираем тенанта. + $best = null; // ['tenant' => int, 'last' => ?string, 'oldestCreated' => string] + foreach ($queued->groupBy('tenant_id') as $tenantId => $runs) { + $candidate = [ + 'tenant' => (int) $tenantId, + 'last' => $lastServed[$tenantId] ?? null, + 'oldestCreated' => (string) $runs->min('created_at'), + ]; + if ($best === null || $this->isBetter($candidate, $best)) { + $best = $candidate; + } + } + + // (3) внутри выбранного тенанта — самый старый queued. + $winner = $queued + ->where('tenant_id', $best['tenant']) + ->sortBy('created_at') + ->first(); + + return (int) $winner->id; + } + + /** + * Запустить одну итерацию планировщика под Redis-локом: если дорожка свободна и + * есть кандидат — задиспатчить его изучение. Лок защищает от гонки нескольких + * тиков (два воркера не отправят два изучения разом). + */ + public function tick(): void + { + $lock = Cache::lock(self::LOCK_KEY, 10); + + if (! $lock->get()) { + return; + } + + try { + $runId = $this->pickNextRunId(); + if ($runId === null) { + return; + } + + // Перечитать прогон под тем же межтенантным соединением: статус мог + // измениться между выбором и диспатчем. + $run = DB::connection(self::CONNECTION) + ->table('autopodbor_runs') + ->where('id', $runId) + ->first(); + + if ($run === null || $run->status !== 'queued') { + return; + } + + RunAutopodborStudyJob::dispatch((int) $run->id, (int) $run->tenant_id); + } finally { + $lock->release(); + } + } + + /** + * $a приоритетнее $b? Приоритет: никогда не обслуженный (last === null) вперёд; + * затем обслуженный раньше вперёд; тай-брейк — более старый created_at. + * + * @param array{tenant:int,last:?string,oldestCreated:string} $a + * @param array{tenant:int,last:?string,oldestCreated:string} $b + */ + private function isBetter(array $a, array $b): bool + { + $aNever = $a['last'] === null; + $bNever = $b['last'] === null; + + if ($aNever !== $bNever) { + return $aNever; // никогда не обслуженный — выше + } + + if (! $aNever && $a['last'] !== $b['last']) { + // оба обслужены: обслуженный раньше — выше + return strcmp((string) $a['last'], (string) $b['last']) < 0; + } + + // тай-брейк: самый старый queued created_at — выше + return strcmp($a['oldestCreated'], $b['oldestCreated']) < 0; + } +} diff --git a/app/tests/Unit/Autopodbor/AutopodborStudySchedulerTest.php b/app/tests/Unit/Autopodbor/AutopodborStudySchedulerTest.php new file mode 100644 index 00000000..0f91c60e --- /dev/null +++ b/app/tests/Unit/Autopodbor/AutopodborStudySchedulerTest.php @@ -0,0 +1,65 @@ +create()->id; + } + $tenantId = $GLOBALS['__sched_tenant_map'][$tenant]; + + $run = AutopodborRun::create([ + 'tenant_id' => $tenantId, 'kind' => 'study', 'status' => $status, + 'competitor_id' => null, 'region_code' => 1, 'params' => [], + 'finished_at' => $finishedAt, + ]); + if ($createdAt !== null) { + // created_at не в timestamps — задаём явно через query builder (мимо fillable), + // чтобы порядок был детерминирован + AutopodborRun::whereKey($run->id)->update(['created_at' => $createdAt]); + $run->refresh(); + } + return $run; +} + +it('дорожка занята: есть running → pickNext возвращает null', function () { + studyRun(1, 'running'); + studyRun(2, 'queued'); + expect(app(AutopodborStudyScheduler::class)->pickNextRunId())->toBeNull(); +}); + +it('чередует тенантов: наименее недавно обслуженный вперёд', function () { + studyRun(1, 'done', finishedAt: now()->toDateTimeString()); + studyRun(1, 'queued'); + studyRun(2, 'done', finishedAt: now()->subHour()->toDateTimeString()); + $t2q = studyRun(2, 'queued'); + expect(app(AutopodborStudyScheduler::class)->pickNextRunId())->toBe($t2q->id); +}); + +it('внутри тенанта берёт самый старый queued', function () { + $old = studyRun(1, 'queued', createdAt: now()->subMinutes(10)->toDateTimeString()); + studyRun(1, 'queued', createdAt: now()->toDateTimeString()); + expect(app(AutopodborStudyScheduler::class)->pickNextRunId())->toBe($old->id); +}); + +it('никогда не обслуженный тенант имеет приоритет', function () { + studyRun(1, 'done', finishedAt: now()->subDay()->toDateTimeString()); + studyRun(1, 'queued'); + $t2q = studyRun(2, 'queued'); + expect(app(AutopodborStudyScheduler::class)->pickNextRunId())->toBe($t2q->id); +});