542b7d2d22
Р6 — задание, брошенное «в работе», держало очередь для всех клиентов навсегда. Причина сбоя обрезается до 900 знаков, иначе портал отвергал отчёт о самом частом виде поломки. Провал отчёта больше не глотается молча — едет в письмо человеку. Приём отчёта ловит любую ошибку и закрывает задание сбойным вместо 500 роботу. Новый сторож creative-jobs:reap каждые 10 минут разбирает пробку. Р7 — обрыв на докладе «готово» объявлял провалом уже сделанную работу. Доклад вынесен из-под обработчика сбоя, повторяется трижды, при неудаче только письмо. Осечка ухода со страницы после «Создать» больше не считается провалом загрузки. Р8 — пустой список размеров считался успехом, и робот заливал одни и те же файлы по кругу, оставляя мусор в живом кабинете. Теперь громкий отказ до похода в Яндекс. Портал 274/274, робот 41/41. Все защиты проверены вырезанием. Выходов снятия заморозки денег по-прежнему четыре.
83 lines
4.2 KiB
PHP
83 lines
4.2 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use App\Models\AdCreativeJob;
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Contracts\Database\Query\Builder;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
/**
|
|
* Сторож зависших заданий робота-грузчика креативов.
|
|
*
|
|
* Задание «в работе» — пробка на всю очередь: пока хоть одно из них живо, выдача заданий
|
|
* отвечает «работы нет» ВСЕМ, и ни одна кампания больше не стартует. Робот умирает молча
|
|
* (упал процесс, перезагрузили сервер, портал не принял отчёт) — и без сторожа пробка
|
|
* вечная: `taken_at` до сих пор никто не читал, а число попыток ничем не ограничивалось.
|
|
*
|
|
* 🔴 Ходим через `pgsql_admin` (роль crm_admin_user, у неё разрешающая политика srv_bypass).
|
|
* На дефолтной роли `crm_app_user` расписание работает БЕЗ tenant-контекста, и RLS отдал бы
|
|
* ноль строк — сторож бодро рапортовал бы об успехе, ничего не разбирая. Ровно тот самый
|
|
* «тихий ноль», от которого этот сторож и защищает.
|
|
*/
|
|
class ReapStuckCreativeJobs extends Command
|
|
{
|
|
protected $signature = 'creative-jobs:reap';
|
|
|
|
protected $description = 'Разобрать пробку: вернуть в очередь задания робота, зависшие «в работе»';
|
|
|
|
/** Столько минут молчания — и считаем, что робот задание бросил. */
|
|
private const STUCK_MINUTES = 30;
|
|
|
|
/** Столько всего попыток даём одному заданию, дальше закрываем сбоем. */
|
|
private const MAX_ATTEMPTS = 3;
|
|
|
|
public function handle(): int
|
|
{
|
|
$deadline = now()->subMinutes(self::STUCK_MINUTES);
|
|
|
|
// taken_at без значения тоже считаем зависшим: такое задание иначе не разобрать
|
|
// ничем, а в работе оно висит и очередь держит.
|
|
$stuck = AdCreativeJob::on('pgsql_admin')
|
|
->where('status', AdCreativeJob::STATUS_TAKEN)
|
|
->where(fn (Builder $q) => $q->whereNull('taken_at')->orWhere('taken_at', '<', $deadline))
|
|
->get();
|
|
|
|
foreach ($stuck as $job) {
|
|
if ($job->attempts >= self::MAX_ATTEMPTS) {
|
|
$job->update([
|
|
'status' => AdCreativeJob::STATUS_FAILED,
|
|
'failure_reason' => sprintf(
|
|
'Робот не отчитался за %d минут, и попытки исчерпаны (%d). Задание закрыто сторожем очереди.',
|
|
self::STUCK_MINUTES,
|
|
$job->attempts,
|
|
),
|
|
'finished_at' => now(),
|
|
]);
|
|
|
|
Log::warning('Задание робота закрыто сторожем: попытки исчерпаны', [
|
|
'job_id' => $job->id, 'campaign_id' => $job->campaign_id, 'attempts' => $job->attempts,
|
|
]);
|
|
$this->warn("Задание #{$job->id}: попытки исчерпаны, закрыто сбоем.");
|
|
|
|
continue;
|
|
}
|
|
|
|
$job->update(['status' => AdCreativeJob::STATUS_QUEUED, 'taken_at' => null]);
|
|
|
|
Log::warning('Задание робота возвращено в очередь: робот не отчитался', [
|
|
'job_id' => $job->id, 'campaign_id' => $job->campaign_id, 'attempts' => $job->attempts,
|
|
]);
|
|
$this->info("Задание #{$job->id}: возвращено в очередь.");
|
|
}
|
|
|
|
if ($stuck->isEmpty()) {
|
|
$this->info('Зависших заданий нет.');
|
|
}
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
}
|