Граббер на 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.5composer 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в requirecj-admin-yii3— тот же per-host файловый лок, чтоFileMutexв граббере. Целевой стек повторяет ровно ту проблему, которую надо закрыть;symfony/lock+PostgreSqlStoreзаменяет оба.
2. Что в граббере сейчас
| Показатель | Значение |
|---|---|
| PHP / Yii | 7.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 |
VideoImport | 43 статуса, 239 использований констант, 813 строк |
Video | 34 статуса, 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–9 | 10–15 (до формальной полноты графа) |
| Порты и handler’ы пайплайна | 10–14 | 10–14 |
| 27 импортёров (шаблон + тираж) | 12–18 | 12–18 |
| API-протокол master/slave + контрактные тесты | 8–12 | 8–12 |
| Перенос 7 воркеров | 10–14 | 10–14 |
| Тесты handler’ов и репозиториев | 8–12 | 8–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–79 | 79–117 |
| Атомарный захват задачи | 0 (сохраняем текущий риск) | 10–15 (блокирующее) |
| Stage 0, фронт/админка, финализация | 45–60 | 45–60 |
| Итого, человеко-дней | 99–139 | 134–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 не закрывает
Одиннадцать пунктов. Пакет закрывает из них один, и то наполовину.
Предусловия — без них машину нельзя объявить корректно
status— не одно состояние, а несколько осей.Video::STATUS_APPROVEDодновременно терминальное «опубликовано», очередь для thumb-phash, очередь для экспорта и множество выборки для листингов. Его опрашивают 6 разных воркеров:ThumbPhashController:45,ApiController:936,AutoImportController:356/659/736,Channel.php:342,ExportController:343.single_statemarking store этого не выражает.- Второе состояние вне модели —
status_send. Колонка есть и уvideos, и уvideo_import, двигается черезVideo::updateAll(['status_send' => VideoImport::STATUS_SENT], …)(ExportController:609,666) — в обход модели и любого marking store. Причём константа чужой машины присваивается строкеvideos. - Прогресс закодирован в data-колонках.
status = APPROVED AND thumb_phash IS NULL AND thumb_hash IS NULL— фактическое состояние здесьthumb_phash IS NULL. Для машины подпайплайн невидим. - Мёртвые и дублирующие состояния.
STATUS_DOWNLOADED = 1иSTATUS_SENT = 1— одно значение под двумя именами (VideoImport.php:69,73).STATUS_MATCHING_TAGSобъявлен и подписан в labels, но нигде не присваивается и не опрашивается. Объявить граф до чистки — зафиксировать мусор как контракт.
Конкурентность — то, чего workflow не делает принципиально
- Нет атомарного захвата задачи. Воркер делает
find()->where(status = X), работает, потом пишет статус. НиFOR UPDATE, ни claim-статуса, ни owner’а.apply()блокировки не добавляет. - Единственная защита —
FileMutex. Локальный файловый лок на хост. Между master и slave, между несколькими хостами и между console-воркером иApiController(24 присвоения статуса, вызывается слейвами по HTTP) он не работает вообще. - Нет таймаутов, реклейма и ретраев. Ни одного механизма возврата зависших
*_SLAVE/*_PROCESS. ВсеINTERVALв коде — окна отчётности. Хуже:ApiController:55фильтруетUPLOADED_TO_CDNпоdt_updated > CURRENT_DATE - INTERVAL '2 days'— строки старше двух суток молча выпадают из пайплайна навсегда. Это буквально «тихая пустая выборка» из §13.
Инфраструктура вокруг машины
- Нет истории переходов. Фактический граф не восстановить из данных, только из кода (а §13 предупреждает, что коду тут верить нельзя). После внедрения машины нельзя доказать, что она не отрезала живой переход. Единственный пункт, который workflow помогает закрыть — подписчиком на
EnteredEvent; но таблицу, миграцию и подписчика писать самим, потому чтоAuditTrailListenerпишет только в PSR-логгер. - Побочные эффекты не транзакционны. Переход = файл на CDN, вызов внешнего API (Ratingapi, ArticleForge, Virustotal, translate), запись в соседнюю таблицу.
apply()внутри транзакции с HTTP-вызовом наружу — распределённая транзакция, которой нет. - Две машины связаны переходом между таблицами.
video_importдоходит доUPLOADED_TO_CDN→ создаётся строкаvideosсоSTATUS_APPROVED(ApiController:770). Этот переход не принадлежит ни одной из двух машин; у workflow нет понятия саги. - Распределённость 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-state | MethodMarkingStore(singleState: false) хранит в свойстве массив — integer-колонка не подойдёт, нужна jsonb / text[]. Ответ на §4 п.1 и п.3: решается схемой, не пакетом | входит в 6–10 дней декомпозиции |
| Таблица истории переходов | AuditTrailListener пишет только в PSR-логгер. Своя таблица + подписчик на EnteredEvent | 1 день |
| Claim-переход | UPDATE … WHERE status = X … FOR UPDATE SKIP LOCKED RETURNING | 2–3 дня |
Reaper зависших по dt_claimed | нет | 2–3 дня |
Сага video_import → videos (§4 п.10) | собирается из событий workflow + messenger | 3–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-messenger | Redis | 0 |
symfony/scheduler | в процессе | 0 |
symfony/rate-limiter | PSR-6 pool → Redis | 0 |
symfony/clock, symfony/uid | — | 0 |
Почему workflow здесь самый безопасный пакет набора
Он не хранит ничего своего. Ставим MethodMarkingStore(singleState: true, property: 'status') поверх существующей integer-колонки с существующими значениями констант. Старое приложение продолжает писать status напрямую; новое читает то, что там оказалось. Дрейфа не бывает — нечему дрейфовать.
Цена бесплатности: guard’ы действуют только на переходы нового приложения. На время параллельного прогона машина — гардрейл для нового кода и наблюдатель для старого, а не инвариант данных.
Три места, где параллельный прогон ломается
1. Локи двух приложений не пересекаются. Проверено по исходникам:
| Вызов | Ключ | |
|---|---|---|
Yii2 yii\mutex\PgsqlMutex | pg_try_advisory_lock(:key1, :key2) — двухаргументная форма | unpack('n2', sha1($name, true)) |
Symfony PostgreSqlStore | pg_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-модуля.
- Stage 0 и контрактная оснастка. Без неё «зелёные тесты» как критерий готовности стадии не работают.
- API-модуль — протокол master/slave (
ApiController, 1012 строк). Контрактные тесты на него обязательны: это единственное место, где расхождение с legacy видно сразу. - Console-модуль — здесь машина, вариант B. Внутрь этого модуля входят и определения машин с guard’ами, и декомпозиция перегруженной колонки (§4 п.1–4), и атомарный захват с реклеймом (§4 п.5–7, 10–15 дней). Стадии переносятся по одной: стадия целиком уходит новому приложению и одновременно выключается в кроне старого — см. §7 п.1.
- Фронт/админка, затем финализация.
Не откладывать машину на «после миграции»: 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, а не измерение.
🔗 Связанные заметки
- Миграция Yii2 → Yii3 (концептуальный гайд) — концепты и порядок, поверх которых лежит эта заметка
- PHPStan — уровни по слоям и strict-rules — статический анализ по слоям
- Claude флоу — как вести работу по спеке и плану с агентом (калибровка скорости взята оттуда)
Источник: измерение кодовой базы
sexu-grabber(веткаdev, коммитc6ac55f) иcj-admin-yii3, проверка метаданных и исходников пакетов через composer. Август 2026.