Files
portal/app/app/Jobs/Autopodbor/RunAutopodborResolveJob.php
T
Дмитрий 43e29823a8 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>
2026-07-05 15:28:51 +03:00

105 lines
3.6 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Jobs\Autopodbor;
use App\Models\AutopodborCompetitor;
use App\Models\AutopodborRun;
use App\Services\Autopodbor\Agent\CompetitorAgent;
use App\Services\Autopodbor\Agent\Dto\ResolveByNameRequest;
use App\Services\Autopodbor\AutopodborDedup;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
class RunAutopodborResolveJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public array $backoff = [15, 60, 300];
public function __construct(public int $runId)
{
// Резолв по имени — тоже шаг 1 (ИИ-поиск кандидатов), поэтому на одной очереди с поиском.
$this->onQueue('autopodbor-find');
}
/**
* Защита от нахлёста: один прогон резолва не обрабатывается двумя воркерами разом (ретрай).
*
* @return array<int, object>
*/
public function middleware(): array
{
return [new WithoutOverlapping((string) $this->runId)];
}
public function handle(CompetitorAgent $agent, AutopodborDedup $dedup): void
{
$run = AutopodborRun::findOrFail($this->runId);
// Выставляем tenant-контекст сессионно
DB::statement("SELECT set_config('app.current_tenant_id', ?, false)", [(string) $run->tenant_id]);
// Идемпотентность: завершённый прогон повторно не обрабатываем (защита от лишнего ретрая/повторного dispatch).
if (in_array($run->status, ['done', 'empty', 'failed'], true)) {
return;
}
$run->update(['status' => 'running', 'started_at' => now()]);
try {
$p = $run->params;
$res = $agent->resolveByName(new ResolveByNameRequest(
name: $p['name'],
regionCode: (int) $run->region_code,
));
$unique = $dedup->dedupCompetitors($res->candidates);
if ($unique === []) {
$run->update(['status' => 'empty', 'finished_at' => now()]);
return;
}
foreach ($unique as $c) {
AutopodborCompetitor::updateOrCreate(
[
'tenant_id' => $run->tenant_id,
'search_run_id' => $run->id,
'dedup_key' => $c['dedup_key'],
],
[
'name' => $c['name'],
'description' => $c['description'] ?? null,
'is_federal' => (bool) ($c['is_federal'] ?? false),
'relevance_pct' => null,
'origin' => 'resolve',
'site_url' => $c['site_url'] ?? null,
'directory_urls' => $c['directory_urls'] ?? [],
'provenance' => $c['provenance'] ?? [],
]
);
}
$run->update(['status' => 'done', 'finished_at' => now()]);
} catch (\Throwable $e) {
$run->update([
'status' => 'failed',
'error_code' => substr($e->getMessage(), 0, 64),
'finished_at' => now(),
]);
throw $e;
}
}
}