perf(autopodbor): разнести шаги по очередям — find (ИИ) и study (тупой слой)

Раньше поиск, резолв и изучение делили одну очередь autopodbor — при одном
воркере быстрый шаг 2 (изучение) стоял за тяжёлым шагом 1 (поиск с ИИ) другого
клиента. Теперь два процесса:
  • шаг 1 «собрать поле» (поиск + резолв по имени, ИИ+внешние) → autopodbor-find
  • шаг 2 «дайте источники / изучение» (без ИИ) → autopodbor-study
Идут параллельно, друг друга не держат. Замок WithoutOverlapping по runId цел.

Запуск теперь двумя воркерами:
  queue:work redis --queue=autopodbor-find  --timeout=3600
  queue:work redis --queue=autopodbor-study --timeout=3600

TDD: AutopodborQueueThrottleTest 4/4, автоподбор 434/434.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-05 15:28:51 +03:00
parent bdecd08dcb
commit 43e29823a8
4 changed files with 30 additions and 23 deletions
@@ -27,8 +27,8 @@ class RunAutopodborResolveJob implements ShouldQueue
public function __construct(public int $runId)
{
// Та же выделенная очередь, что и поиск — единый глобальный потолок внешних сервисов.
$this->onQueue('autopodbor');
// Резолв по имени — тоже шаг 1 (ИИ-поиск кандидатов), поэтому на одной очереди с поиском.
$this->onQueue('autopodbor-find');
}
/**
@@ -40,9 +40,9 @@ class RunAutopodborSearchJob implements ShouldQueue
public function __construct(public int $runId)
{
// Выделенная очередь: число её воркеров = глобальный потолок одновременных подборов
// (лимиты EXA/xfetch/AITUNNEL — на КЛЮЧ, общий на всех клиентов).
$this->onQueue('autopodbor');
// Шаг 1 «собрать поле» (ИИ + внешние сервисы) — своя очередь, отдельный воркер-процесс,
// чтобы тяжёлый поиск не стоял в одной линии с быстрым шагом 2 (изучением) другого клиента.
$this->onQueue('autopodbor-find');
}
/**
@@ -32,8 +32,9 @@ class RunAutopodborStudyJob implements ShouldQueue
public function __construct(public int $runId)
{
// Та же выделенная очередь, что и поиск — единый глобальный потолок внешних сервисов.
$this->onQueue('autopodbor');
// Шаг 2 «изучение / дайте источники» — тупой слой (без ИИ). ОТДЕЛЬНАЯ очередь + свой воркер,
// чтобы изучение шло параллельно поиску, а не ждало его в общей линии.
$this->onQueue('autopodbor-study');
}
/**
@@ -8,33 +8,39 @@ use App\Jobs\Autopodbor\RunAutopodborStudyJob;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Support\Facades\Queue;
// Масштаб: лимиты внешних сервисов — на КЛЮЧ (общий на всех клиентов). Джобы автоподбора идут на
// ВЫДЕЛЕННУЮ очередь autopodbor — число её воркеров = глобальный потолок одновременных подборов.
// Масштаб: два шага разнесены по РАЗНЫМ очередям (разные процессы-воркеры), чтобы не толкались.
// Шаг 1 «собрать поле» с ИИ (поиск + резолв по имени) → очередь autopodbor-find.
// Шаг 2 «изучение / дайте источники» — тупой слой → очередь autopodbor-study.
// Так быстрый шаг 2 одного клиента не стоит за тяжёлым шагом 1 другого.
// Плюс защита от нахлёста (WithoutOverlapping) на случай ретрая/повторного dispatch.
it('поиск уходит на выделенную очередь autopodbor', function () {
it('шаг 1 (поиск) уходит на очередь autopodbor-find', function () {
Queue::fake();
RunAutopodborSearchJob::dispatch(123);
Queue::assertPushedOn('autopodbor', RunAutopodborSearchJob::class);
Queue::assertPushedOn('autopodbor-find', RunAutopodborSearchJob::class);
});
it('поиск защищён от нахлёста — middleware WithoutOverlapping по runId', function () {
$mw = (new RunAutopodborSearchJob(123))->middleware();
expect($mw)->toHaveCount(1)
->and($mw[0])->toBeInstanceOf(WithoutOverlapping::class);
});
it('шаг 2 (изучение) и резолв — на той же очереди и защищены от нахлёста', function () {
it('резолв по имени — тоже шаг 1, на очереди autopodbor-find', function () {
Queue::fake();
RunAutopodborStudyJob::dispatch(7);
RunAutopodborResolveJob::dispatch(8);
Queue::assertPushedOn('autopodbor', RunAutopodborStudyJob::class);
Queue::assertPushedOn('autopodbor', RunAutopodborResolveJob::class);
Queue::assertPushedOn('autopodbor-find', RunAutopodborResolveJob::class);
});
expect((new RunAutopodborStudyJob(7))->middleware()[0])->toBeInstanceOf(WithoutOverlapping::class)
it('шаг 2 (изучение) уходит на ОТДЕЛЬНУЮ очередь autopodbor-study', function () {
Queue::fake();
RunAutopodborStudyJob::dispatch(7);
Queue::assertPushedOn('autopodbor-study', RunAutopodborStudyJob::class);
});
it('все три задания защищены от нахлёста — middleware WithoutOverlapping по runId', function () {
expect((new RunAutopodborSearchJob(123))->middleware())->toHaveCount(1)
->and((new RunAutopodborSearchJob(123))->middleware()[0])->toBeInstanceOf(WithoutOverlapping::class)
->and((new RunAutopodborStudyJob(7))->middleware()[0])->toBeInstanceOf(WithoutOverlapping::class)
->and((new RunAutopodborResolveJob(8))->middleware()[0])->toBeInstanceOf(WithoutOverlapping::class);
});