Граббер на Yii3: машина состояний и рантайм-пакеты

Оценка трудозатрат на перенос sexu-grabber с Yii2 на Yii3 в двух вариантах — «как есть» и с формальной машиной состояний на symfony/workflow — плюс набор рантайм-пакетов и план параллельного прогона без изменения схемы. Все числа получены измерением кода, все версии пакетов — проверкой метаданных, не по памяти.

Применяется поверх Миграция Yii2 → Yii3 (концептуальный гайд); ссылки вида «§7» — на его разделы. Всё, что гайд уже проговаривает — слои, контрактные тесты, статический анализ, документооборот, persistence — здесь не повторяется.


1. Платформа и набор пакетов

Целевая платформа — PHP 8.4, как в cj-admin-yii3 (Dockerfile:16 → php:8.4-fpm-alpine, bitbucket-pipelines.yml:1 → php:8.4-cli). Линия Symfony — ^8.1 (её порог php >=8.4.1).

ПакетЗакрываетПроверенная деталь
symfony/workflowядро: граф, guard’ы, событияMermaidDumper + GraphvizDumper + PlantUmlDumper; GuardListener + GuardExpression (декларативные guard’ы на ExpressionLanguage); AuditTrailListener
symfony/lock§3 п.5, 6 — распределённый локPostgreSqlStore — чистый pg_try_advisory_lock(:key), таблицы не создаёт, конструктор принимает голый \PDO или DSN, Doctrine не нужен. Таблица нужна только PdoStore — его не берём
symfony/messenger§3 п.7, 9, 11 — ретраи, DLQ, идемпотентностьMultiplierRetryStrategy (backoff), FailedMessageProcessingMiddleware + failure transport (dead-letter), RejectRedeliveredMessageMiddleware, DeduplicateMiddleware
symfony/redis-messengerтранспортВстроенные транспорты messenger — только InMemory и Sync. Требует ext-redis (не predis)
symfony/scheduler§3 п.7 — расписания вместо cron-простынейРаботает поверх messenger
symfony/rate-limiter§3 п.9 — внешние APIХранилища в пакете: CacheStorage (PSR-6) и InMemoryStorage. Прямо под Ratingapi / ArticleForge / Virustotal / HF-Space
symfony/clock + symfony/uid§3 п.7, 8тестируемые таймстемпы для таймаутов; correlation-id для трассировки перехода через master↔slave
composer require symfony/workflow:^8.1 symfony/lock:^8.1 symfony/messenger:^8.1 symfony/redis-messenger:^8.1 symfony/scheduler:^8.1 symfony/rate-limiter:^8.1 symfony/clock:^8.1 symfony/uid:^8.1

Проверено установкой: набор ставится чисто, из лишнего только symfony/options-resolver, контракты и полифилы. Ни Doctrine, ни DBAL.

Инфраструктура в cj-admin-yii3 уже готова: ext-redis в образе собран (docker-php-ext-enable redis, Dockerfile:55, с fallback-сборкой phpredis из исходников), Redis 7 поднят сервисом в CI (bitbucket-pipelines.yml:22), из расширений есть также pdo_pgsql, intl, pcntl, bcmath, apcu, pcov. Для граббера Redis — новый инфраструктурный узел, сейчас его нет вообще.

Не брать: yiisoft/queue 3.x, yiisoft/queue-db, yiisoft/queue-amqp — у всех трёх нет ни одного стабильного релиза, только 3.0.x-dev и ветки.

Два дефекта конфигов — исправить до старта.

1. cj-admin-yii3/composer.json:8 объявляет "php": ">=8.1" при фактическом рантайме 8.4, а composer.lock там не содержит platform-overrides — резолвинг идёт против PHP машины разработчика. Локнув на хосте с 8.5, можно втащить пакет с требованием >=8.5, который тихо упадёт в контейнере 8.4. Фикс: "require": {"php": ">=8.4"} и "config": {"platform": {"php": "8.4"}}. 2. sexu-grabber/composer.json:49 пиннит в config.platform только php: "7.4.11", без расширений. На хосте с PHP 8.5 composer install падает: phpdocumentor/reflection-docblock 5.1.0 требует ext-filter ^7.1, composer видит 8.5.5 (цепочка codeception 4.1.6 → codeception/stub → phpunit 8.5.8 → phpspec/prophecy 1.11.0). Добавление ext-filter в composer.json не помогает: composer install читает platform-overrides из lock-файла, а там только {"php": "7.4.11"}. Перелокировать сейчас нельзя — composer update --lock падает на неотносящемся конфликте kartik-v/yii2-widget-select2: dev-master ↔ kartik-v/yii2-krajee-base >=3.0.4. Обход: composer install --ignore-platform-req=ext-filter.

yiisoft/mutex-file в require cj-admin-yii3 — тот же per-host файловый лок, что FileMutex в граббере. Целевой стек повторяет ровно ту проблему, которую надо закрыть; symfony/lock + PostgreSqlStore заменяет оба.


2. Что в граббере сейчас

ПоказательЗначение
PHP / Yii7.4.11 / Yii 2.0.54, Postgres
Прикладной код~51 600 строк: common 15 002, console 15 701, frontend 20 407, backend 537
AR-моделей / контроллеров / вьюх / миграций58 / 50 / 131 / 127
Моделей со статусами17
VideoImport43 статуса, 239 использований констант, 813 строк
Video34 статуса, 1499 строк
Малые машиныRatingapi 7, ChannelsTask/SiteTasks/TagsTask по 5, Channel 4, Abuses 4, ArticleForgeApi 4, Rewriteapi 3, 2× suggestion по 3, ChannelSuggested 2
Всего использований STATUS_580
Прямых присвоений ->status =104 в 30+ файлах (топ: ApiController 24, Video.php 17, AutoImportController 8)
Обход модели12 raw-SQL UPDATE … SET status, 6 updateAll со статусом
Методов-переходов (setStatus/changeStatus)0
История переходовнет — ни таблицы, ни лога
Блокировки3 beginTransaction на весь проект, 0 FOR UPDATE / GET_LOCK
Мьютексyii\mutex\FileMutex — console/config/main.php:34, frontend/config/main.php:52
Redisне используется вообще

Ключевое: статус здесь одновременно состояние, очередь и распределённый лок. Master отдаёт работу слейву через STATUS_DELEGATED / *_SLAVE по HTTP (ApiController), без транзакций и без владельца. Половина 43 статусов — терминальные ошибки (-1…-21), то есть граф уже фактически машина, просто нигде не объявленная.


3. Оценка: миграция «как есть» vs с машиной состояний

Единица — человеко-день одного разработчика с агентом, календарь из 20 рабочих дней в месяце. Калибровка по cj-admin-yii3: 52 722 новых строк + 307 тестовых файлов + 55 планов за 4.5 календарных месяца (07.04.2026 → 25.08.2026, 967 коммитов).

Отличаются только модули пайплайна: ApiController (1012 строк, протокол master/slave), 7 console-воркеров, 27 импортёров, Video.php, VideoImport.php. Stage 0, фронт/админка и финализация одинаковы.

РаботаA: как естьB: + workflow на Video/VideoImport
Реверс поведения пайплайна6–910–15 (до формальной полноты графа)
Порты и handler’ы пайплайна10–1410–14
27 импортёров (шаблон + тираж)12–1812–18
API-протокол master/slave + контрактные тесты8–128–12
Перенос 7 воркеров10–1410–14
Тесты handler’ов и репозиториев8–128–12
Декомпозиция перегруженной колонки status—6–10 (предусловие, §4 п.1)
2 определения машин, marking store, DI, dumper—3–4
Guards + подписчик истории + таблица—5–7
Приведение присвоений к apply(), вычистка raw SQL—3–5
Тесты графа, guard’ов, недостижимых состояний—4–6
Пайплайн54–7979–117
Атомарный захват задачи0 (сохраняем текущий риск)10–15 (блокирующее)
Stage 0, фронт/админка, финализация45–6045–60
Итого, человеко-дней99–139134–192
Календарно5–7 мес6.5–9.5 мес

Дельта B над A: +35–53 человеко-дня, +35–40% к сроку. Из них 25–38 — собственно машина, 10–15 — атомарность, без которой машина даёт ложную уверенность.

Три замечания:

  • Дельта посчитана как инкремент внутри миграции. Отдельным заходом после неё та же работа стоит 65–99 дней — второй вход в тот же код, повторный реверс графа, свой цикл спека/план/ретро.
  • Вариант A не сохраняет статус-кво по надёжности: 43 статуса переносятся такими же неявными, только уже в handler’ах вместо контроллеров. §0 гайда называет это ошибкой планирования №1.
  • В A атомарность можно не делать — сегодня её нет, это принятый риск. В B нельзя: guard проверит состояние, которое к моменту записи устарело, и машина будет узаконивать гонку вместо того, чтобы её ловить.

4. Чего symfony/workflow не закрывает

Одиннадцать пунктов. Пакет закрывает из них один, и то наполовину.

Предусловия — без них машину нельзя объявить корректно

  1. status — не одно состояние, а несколько осей. Video::STATUS_APPROVED одновременно терминальное «опубликовано», очередь для thumb-phash, очередь для экспорта и множество выборки для листингов. Его опрашивают 6 разных воркеров: ThumbPhashController:45, ApiController:936, AutoImportController:356/659/736, Channel.php:342, ExportController:343. single_state marking store этого не выражает.
  2. Второе состояние вне модели — status_send. Колонка есть и у videos, и у video_import, двигается через Video::updateAll(['status_send' => VideoImport::STATUS_SENT], …) (ExportController:609,666) — в обход модели и любого marking store. Причём константа чужой машины присваивается строке videos.
  3. Прогресс закодирован в data-колонках. status = APPROVED AND thumb_phash IS NULL AND thumb_hash IS NULL — фактическое состояние здесь thumb_phash IS NULL. Для машины подпайплайн невидим.
  4. Мёртвые и дублирующие состояния. STATUS_DOWNLOADED = 1 и STATUS_SENT = 1 — одно значение под двумя именами (VideoImport.php:69,73). STATUS_MATCHING_TAGS объявлен и подписан в labels, но нигде не присваивается и не опрашивается. Объявить граф до чистки — зафиксировать мусор как контракт.

Конкурентность — то, чего workflow не делает принципиально

  1. Нет атомарного захвата задачи. Воркер делает find()->where(status = X), работает, потом пишет статус. Ни FOR UPDATE, ни claim-статуса, ни owner’а. apply() блокировки не добавляет.
  2. Единственная защита — FileMutex. Локальный файловый лок на хост. Между master и slave, между несколькими хостами и между console-воркером и ApiController (24 присвоения статуса, вызывается слейвами по HTTP) он не работает вообще.
  3. Нет таймаутов, реклейма и ретраев. Ни одного механизма возврата зависших *_SLAVE / *_PROCESS. Все INTERVAL в коде — окна отчётности. Хуже: ApiController:55 фильтрует UPLOADED_TO_CDN по dt_updated > CURRENT_DATE - INTERVAL '2 days' — строки старше двух суток молча выпадают из пайплайна навсегда. Это буквально «тихая пустая выборка» из §13.

Инфраструктура вокруг машины

  1. Нет истории переходов. Фактический граф не восстановить из данных, только из кода (а §13 предупреждает, что коду тут верить нельзя). После внедрения машины нельзя доказать, что она не отрезала живой переход. Единственный пункт, который workflow помогает закрыть — подписчиком на EnteredEvent; но таблицу, миграцию и подписчика писать самим, потому что AuditTrailListener пишет только в PSR-логгер.
  2. Побочные эффекты не транзакционны. Переход = файл на CDN, вызов внешнего API (Ratingapi, ArticleForge, Virustotal, translate), запись в соседнюю таблицу. apply() внутри транзакции с HTTP-вызовом наружу — распределённая транзакция, которой нет.
  3. Две машины связаны переходом между таблицами. video_import доходит до UPLOADED_TO_CDN → создаётся строка videos со STATUS_APPROVED (ApiController:770). Этот переход не принадлежит ни одной из двух машин; у workflow нет понятия саги.
  4. Распределённость master↔slave. Часть переходов физически совершает слейв и сообщает по HTTP. Решение — одна машина на master, слейв только репортит, либо две согласованные — архитектурное; workflow здесь потребитель решения.

Пункты 5, 6, 7 не зависят от workflow и снимают реальные инциденты сами по себе — зависшие *_SLAVE и строки, потерянные через INTERVAL '2 days'. Внедряются вместе с console-модулем, 10–15 дней (см. §8).


5. Что писать самим — пакетами не закрывается

ЧтоПочему пакета нетОбъём
Своя реализация MarkingStoreInterfaceВ symfony/workflow только два файла: интерфейс и MethodMarkingStore (через геттер/сеттер). Для сущностей с readonly он не подойдёт1–2 дня
Колонка под multiple-stateMethodMarkingStore(singleState: false) хранит в свойстве массив — integer-колонка не подойдёт, нужна jsonb / text[]. Ответ на §4 п.1 и п.3: решается схемой, не пакетомвходит в 6–10 дней декомпозиции
Таблица истории переходовAuditTrailListener пишет только в PSR-логгер. Своя таблица + подписчик на EnteredEvent1 день
Claim-переходUPDATE … WHERE status = X … FOR UPDATE SKIP LOCKED RETURNING2–3 дня
Reaper зависших по dt_claimedнет2–3 дня
Сага video_import → videos (§4 п.10)собирается из событий workflow + messenger3–5 дней
Правило статического анализа «->status = только внутри машины»готовые запретители покрывают вызовы, но не запись в свойство0.5 дня
Snapshot-гейт «Mermaid в docs = сгенерированное из кода»—0.5 дня

6. Гардрейлы, специфичные для машины состояний

Общие гейты качества берутся из §6, §10 и §11 гайда и здесь не дублируются. Специфичны для машины только эти четыре:

  • Диаграмма как snapshot-тест. MermaidDumper генерирует граф из определения машины; гейт сравнивает сгенерированное с закоммиченным в docs/. Спека физически не может разойтись с кодом — лечит самое слабое место §12.
  • Guard’ы как декларация. GuardListener + GuardExpression на ExpressionLanguage: инварианты переезжают из 104 мест в один читаемый файл, который ревьюится глазами.
  • Машина сначала в режиме отчёта. §10 буквально: подписчик логирует фактические переходы, apply() ещё не вызывается. Первый прогон даст авторитетный список расхождений между объявленным графом и реальностью — включая мёртвые статусы и дубли значений.
  • Backed enum как компилятор-гейт для §4 п.4. enum ImportStatus: int с двумя кейсами одного значения — фатальная ошибка PHP на загрузке. Дубль STATUS_DOWNLOADED = 1 / STATUS_SENT = 1 перестанет быть возможным без всякого линтера.

7. Параллельный прогон: набор внедряется без изменения схемы

Ни один из восьми пакетов не требует таблицы.

ПакетГде живёт состояниеСхема
symfony/workflowнигде — читает и пишет одно свойство субъекта0
symfony/lockсессия Postgres (advisory)0
symfony/messenger + redis-messengerRedis0
symfony/schedulerв процессе0
symfony/rate-limiterPSR-6 pool → Redis0
symfony/clock, symfony/uid—0

Почему workflow здесь самый безопасный пакет набора

Он не хранит ничего своего. Ставим MethodMarkingStore(singleState: true, property: 'status') поверх существующей integer-колонки с существующими значениями констант. Старое приложение продолжает писать status напрямую; новое читает то, что там оказалось. Дрейфа не бывает — нечему дрейфовать.

Цена бесплатности: guard’ы действуют только на переходы нового приложения. На время параллельного прогона машина — гардрейл для нового кода и наблюдатель для старого, а не инвариант данных.

Три места, где параллельный прогон ломается

1. Локи двух приложений не пересекаются. Проверено по исходникам:

ВызовКлюч
Yii2 yii\mutex\PgsqlMutexpg_try_advisory_lock(:key1, :key2) — двухаргументная формаunpack('n2', sha1($name, true))
Symfony PostgreSqlStorepg_try_advisory_lock(:key) — односаргументная формаcrc32((string) $key), где (string) $key = $this->resource

В Postgres это разные пространства блокировок — они друг друга не видят. Плюс граббер сегодня вообще на FileMutex. Итог: symfony/lock в новом приложении даёт ноль взаимного исключения со старым.

Поскольку старое приложение не правим, вывод такой: symfony/lock защищает воркеры нового приложения только друг от друга. Значит стадию нельзя отдавать новому приложению частично — только целиком, с одновременным выключением её в кроне старого. Пока стадия делится между приложениями, единственная защита на ней — та же, что сегодня, то есть FileMutex и разнесение по времени.

Если когда-нибудь понадобится взаимное исключение между приложениями без разделения владения — это класс мьютекса в Yii2, вызывающий pg_try_advisory_lock(crc32($resource)) тем же строковым ресурсом, ~0.5 дня. Но при полном владении стадией он не нужен.

2. Статус — это очередь, и messenger о ней не знает. Старое приложение забирает работу через where(['status' => X]) и ничем строку не помечает. Новое, получив сообщение из Redis, пойдёт в ту же строку: два скачивания, два вызова платного API, две записи на CDN. Без схемы решается только разделением владения — messenger получает стадии, которые старое приложение не опрашивает, и каждая стадия одновременно выключается в его кроне. Это и есть модель §2: «право записи открывается по мере переноса write-сценариев».

3. scheduler и cron старого приложения выстрелят оба. Общий лок этого не решает (см. п.1), поэтому расписание переносится в scheduler только вместе с выключением соответствующей строки в кроне старого приложения — не раньше.

Чего без схемы не получится

ХотелкаПочему нужна схема
multiple_state для перегруженной колонки (§4 п.1)marking store хранит массив — integer-колонка не подойдёт
Атомарный claim (§4 п.5)нужны claimed_by + claimed_at
Reaper зависших (§4 п.7)нужен claimed_at
История переходов в БД (§4 п.8)нужна таблица
Транзакционный outbox (§4 п.9)Redis не участвует в транзакции Postgres

Аддитивный минимум, если тратим 1–2 дня

claimed_by, claimed_at на video_import и videos + таблица workflow_transition_log. Одна миграция, ~1 день. Закрывает §4 п.5, 7, 8; открытым остаётся только outbox.

Мина при добавлении колонок в videos / video_import. В ApiController шесть мест с поголовным копированием атрибутов — строки 714, 734, 735, 768, 787, 794: $newVideo->attributes = $video->attributes;. Любая новая колонка автоматически скопируется на новую строку, и клон приедет с чужим захватом. Шесть строк правки в старом приложении, но найти их надо до, а не после.


8. Рекомендуемый порядок

Старое приложение не правим — вся работа идёт в новом. Порядок модулей по §2 гайда, машина внедряется внутри console-модуля.

  1. Stage 0 и контрактная оснастка. Без неё «зелёные тесты» как критерий готовности стадии не работают.
  2. API-модуль — протокол master/slave (ApiController, 1012 строк). Контрактные тесты на него обязательны: это единственное место, где расхождение с legacy видно сразу.
  3. Console-модуль — здесь машина, вариант B. Внутрь этого модуля входят и определения машин с guard’ами, и декомпозиция перегруженной колонки (§4 п.1–4), и атомарный захват с реклеймом (§4 п.5–7, 10–15 дней). Стадии переносятся по одной: стадия целиком уходит новому приложению и одновременно выключается в кроне старого — см. §7 п.1.
  4. Фронт/админка, затем финализация.

Не откладывать машину на «после миграции»: 65–99 дней против 26–39 внутри console-модуля.

Первый шаг внутри console-модуля — режим отчёта по §10 гайда: подписчик логирует фактические переходы, apply() ещё не вызывается. Первый прогон даёт авторитетный список расхождений объявленного графа с реальностью, включая мёртвые статусы и дубли значений.


9. Что проверено, а что нет

Проверено установкой и чтением исходников: версии и php-констрейнты всех восьми пакетов; наличие PostgreSqlStore (и что он не создаёт таблицу); MermaidDumper; MultiplierRetryStrategy, FailedMessageProcessingMiddleware, RejectRedeliveredMessageMiddleware, DeduplicateMiddleware; AuditTrailListener пишет только в PSR-логгер; MethodMarkingStore — единственная реализация marking store; хранилища rate-limiter; ext-redis в требованиях redis-messenger; ключи advisory-локов в yii\mutex\PgsqlMutex и Symfony\Component\Lock\Store\PostgreSqlStore; отсутствие стабильных релизов у yiisoft/queue*; PHP 8.4 в Dockerfile и bitbucket-pipelines.yml cj-admin-yii3; отсутствие platform-overrides в его lock-файле; все метрики кодовой базы граббера из §2.

Не проверено:

  • патч-версия, на которую сейчас указывает тег php:8.4 в образе — для Symfony ^8.1 нужен >=8.4.1; если тег окажется ниже, порог не пройдёт;
  • поведение MethodMarkingStore на реальных данных граббера — совместимость с существующей integer-колонкой подтверждена только сигнатурой класса, кода против живой БД не запускалось;
  • числа в §3 — это оценка, калиброванная по фактической скорости cj-admin-yii3, а не измерение.

🔗 Связанные заметки


Источник: измерение кодовой базы sexu-grabber (ветка dev, коммит c6ac55f) и cj-admin-yii3, проверка метаданных и исходников пакетов через composer. Август 2026.