Перейти к содержимому

Долгая операция шагами - порции, состояние, прогресс

Обрабатываем десятки тысяч записей так, чтобы операция пережила обрыв и показала прогресс. Разбираем пошаговый итератор ядра: порции, состояние, прогресс и способы запуска.

Механика

Длинная операция в одном запросе почти обречена на обрыв по времени выполнения. Ядро предлагает готовый механизм: наследник итератора выполняет работу порциями и сам помнит, где остановился.

Итератор наследуется от базового класса ядра и объявляет свой модуль-владелец операции. Внутри реализуется единственный метод, который выполняет одну порцию работы за вызов.

Состояние между вызовами живёт в массиве, который метод получает именно по ссылке. Туда кладут число сделанных шагов, общее количество и свои данные для продолжения работы.

Возвращённое методом значение управляет продолжением всей длительной операции. Одна константа означает «продолжить со следующей порции», другая - «работа закончена, больше не вызывать».

Массив состояния сохраняется целиком при каждом вызове этого метода. Класть в него тысячу идентификаторов не стоит: достаточно последнего обработанного, от которого можно продолжить.

Итератор выполняется как обычный отложенный агент самой платформы. Он выполняется на обычных запросах посетителей, но не чаще одного раза в секунду, и на пустом сайте не сдвинется с места.

Запускают итератор одним из двух способов. Обычный запуск годится, когда модуль уже загружен, а запуск по имени класса - для установки модуля или ситуаций, где модуль ещё не подключён.

Параметры запуска передают отдельным массивом из простых скалярных значений языка. Внутри итератора их читают штатным методом, а не через глобальные переменные и не через настройки модуля.

Прогресс работы показывает штатный элемент интерфейса административной части. Он берёт числа сделанного и общего из состояния и опрашивает сервер раз в несколько минут, поэтому мгновенной шкалы от него ждать не стоит.

Одноразовую задачу можно попросить удалить саму себя сразу после завершения. Отдельный признак у класса означает, что после завершения система уберёт файл, и это удобно для разовых миграций данных.

Работа порциями меняет заодно и требования к самому коду обработки данных. Порция должна быть самостоятельной: она не может рассчитывать на данные, накопленные в памяти на прошлом вызове, потому что между вызовами процесс уже завершился.

Отсюда простое правило: всё, что нужно следующей порции, лежит либо в состоянии итератора, либо в самих данных. Отметка обработки на записи надёжнее любого счётчика, потому что переживает и обрыв, и перезапуск операции с нуля.

Шаги

  1. Оценить общий объём работы и выбрать разумный размер порции для одного вызова.
  2. Завести класс итератора со своим модулем и методом обработки ровно одной порции.
  3. Сложить в массив состояния счётчики шагов и точку продолжения работы.
  4. Вернуть нужную константу в зависимости от того, закончена ли уже вся работа.
  5. Запустить итератор с подходящей задержкой и нужными параметрами запуска.
  6. Показать прогресс в административной части и предусмотреть остановку на полпути.

Код

Заводим итератор:

namespace Vendor\Module\Stepper;
use Bitrix\Main\Update\Stepper;
class PriceRecalc extends Stepper
{
protected static $moduleId = 'vendor.module'; // модуль-владелец итератора
// protected $deleteFile = true; // для разовой миграции данных
public function execute(array &$option)
{
if (empty($option)) { // первый запуск
$option['steps'] = 0;
$option['count'] = $this->calculateTotal();
$option['last_id'] = 0;
}
$done = $this->processBatch((int)$option['last_id'], 50);
$option['last_id'] = $done['last_id'];
$option['steps'] += $done['count'];
return $option['steps'] < $option['count']
? self::CONTINUE_EXECUTION
: self::FINISH_EXECUTION;
}
}

Метод обрабатывает ровно одну порцию и сразу возвращает управление. Размер порции подбирают по времени: полсекунды работы на вызов - разумный ориентир, при котором посетитель ничего не замечает.

Обрабатываем порцию с точкой продолжения:

private function processBatch(int $lastId, int $limit): array
{
$rows = ElementTable::getList([
'filter' => ['>ID' => $lastId], 'order' => ['ID' => 'ASC'], 'limit' => $limit,
])->fetchAll();
foreach ($rows as $row) { $this->recalc($row); }
return ['last_id' => end($rows)['ID'] ?? $lastId, 'count' => count($rows)];
}
// выборка идёт по возрастанию идентификатора, а не по смещению страницы

Продолжение строят на последнем обработанном идентификаторе, а не на номере страницы. Смещение врёт, как только данные меняются во время работы, а по идентификатору порядок остаётся предсказуемым.

Запускаем итератор:

if (\Bitrix\Main\Loader::includeModule('vendor.module')) {
PriceRecalc::bind(120); // старт через две минуты
}
// в установщике модуля запускают по имени класса: модуль там ещё не загружен
Stepper::bindClass(PriceRecalc::class, 'vendor.module', 60);

Обычный запуск подходит для кода, который выполняется при загруженном модуле: обработчик события, страница админки, компонент. Запуск по имени класса нужен установщику и любому месту, где модуль может быть ещё не подключён.

Передаём параметры запуска:

PriceRecalc::bind(60, ['iblockId' => 12, 'priceType' => 2]);
// внутри итератора
$params = $this->getOuterParams(); // простые значения, не объекты
$iblockId = (int)($params['iblockId'] ?? 0);

Параметры передают простыми значениями и читают внутри штатным методом итератора. Объекты и замыкания здесь не переживут сохранения состояния, а настройки модуля для разовых параметров запуска - лишний уровень.

Показываем прогресс:

// на своей странице админки
echo PriceRecalc::getHtml(); // прогресс одного итератора
echo Stepper::getHtml('vendor.module', 'Пересчёт цен'); // все итераторы модуля
// элемент прогресса опрашивает сервер раз в несколько минут

Прогресс собирается из чисел состояния, поэтому общее количество считают заранее. Итератор без известного объёма работы покажет шкалу, которая не двигается, хотя работа идёт.

Останавливаем незавершённую операцию:

// итератор живёт агентом, поэтому снимают его штатным удалением агента
\CAgent::RemoveAgent('\\' . PriceRecalc::class . '::execute();', 'vendor.module');
// параметры обязаны совпадать с регистрацией, иначе агент останется на месте
// состояние итератора при этом остаётся: следующий запуск продолжит с той же точки

Снятие задания останавливает выполнение, но не стирает накопленное состояние. Это удобно при разборе сбоя: работу приостанавливают, разбираются с данными и запускают снова с той же точки.

Ограничения

Итератор двигается вперёд только при живых обращениях посетителей к сайту. На малопосещаемом сайте операция растянется надолго, и такие задачи разумнее держать в задании планировщика сервера.

Порция не должна занимать заметное время. Обработка, растянутая на десять секунд, превращает страницу посетителя в ожидание и сводит на нет всю идею разбиения.

Состояние сериализуется целиком при каждом сохранении. Большие массивы в нём замедляют операцию сильнее, чем сама обработка данных.

Механизм не даёт отката. Прерванная на середине операция оставляет часть данных обработанной, поэтому порции делают идемпотентными или ведут журнал обработанных записей.

Типичные проблемы

Итератор не двигается с места.

Он выполняется как отложенный агент на обычных обращениях посетителей к сайту. Без посетителей и без запуска агентов по расписанию работа вперёд не идёт.

Прогресс показывает неподвижную шкалу.

В состоянии нет общего количества работы. Элемент прогресса считает долю от него, и без этого числа шкала стоит на месте.

Операция начинается заново после каждого запуска.

Метод обработки не сохраняет точку продолжения работы в массиве состояния. Массив состояния передаётся по ссылке, и записывать в него нужно именно там.

Часть записей обработана дважды.

Продолжение построено на смещении страницы, а данные менялись прямо во время работы. Точку продолжения держат на последнем обработанном идентификаторе записи.

Сайт заметно замедлился после запуска.

Порция работы слишком велика и выполняется прямо в запросе живого посетителя. Размер порции подбирают так, чтобы один вызов занимал заметно меньше секунды.

Частые вопросы

Чем итератор лучше обычного агента?

Он умеет то, чего у агента нет из коробки: состояние между запусками, порции и готовый прогресс в интерфейсе. Агент просто выполняется по расписанию и о своей работе ничего не помнит.

Какой размер порции выбрать?

Такой, чтобы один вызов занимал доли секунды. На тяжёлых операциях это единицы записей, на лёгких - сотни; ориентируются по времени, а не по числу.

Можно ли запустить итератор из консоли?

Да, скриптом с подключённым ядром, вызвав выполнение в цикле. Так операция идёт с полной скоростью и не зависит от посещаемости сайта.

Что будет при обновлении продукта во время работы?

Состояние сохранится, а выполнение продолжится после обновления. Опасны не обновления, а изменение самих данных: точка продолжения должна оставаться корректной.

Как понять, что операция закончилась?

Итератор возвращает признак завершения и перестаёт вызываться, а прогресс исчезает из интерфейса. Для надёжности итог записывают в журнал своим сообщением.

Смежное

Первоисточник