Редактор нажал «Утвердить», страница на минуту задумалась, и кнопку нажали ещё раз. Одновременно где-то на другом конце провода внешний сервис — витрина, рассылка, агрегатор — то ли принял статью, то ли нет. Автоматизация, которая стоит между этими двумя точками, теперь должна ответить на очень приземлённый вопрос: что именно уже произошло? Не «что должно было произойти по схеме», а что можно утверждать прямо сейчас.

Эта статья про то, как в Directus Flows устроить цепочку «черновик — одобрение — публикация — уведомление» так, чтобы у каждой статьи в каждый момент было понятное состояние, а у редактора — понятный следующий шаг. Сразу договоримся об одном: произвольный внешний API не участвует в транзакции вашей базы. Если запрос ушёл и ответ не пришёл, никакая магия Directus не вернёт вас в состояние «ничего не было». Поэтому всё, что мы построим, — это дисциплина состояний, а не откат.

Что видит редактор: состояние как часть материала

Начнём не с узлов автоматизации, а с того, что находится перед глазами у человека. Редактор открывает материал и видит поле состояния публикации. Если это поле честно отражает реальность, половина проблем с повторными нажатиями исчезает сама: кнопка «Утвердить» просто не имеет смысла в состоянии, где публикация уже идёт.

Заведём в коллекции материалов отдельное поле, назовём его, например, publish_state. Не смешивайте его с редакционным статусом «черновик / на проверке / утверждён»: это разные вопросы. Первый отвечает на «можно ли это публиковать», второй — «где сейчас физически находится публикация». Набор значений для нашей схемы такой:

  • idle — публикация не запрашивалась;
  • approved — редактор утвердил материал, автоматика ещё не взялась за дело;
  • publishing — запрос во внешний сервис отправляется или уже отправлен, итог неизвестен;
  • published — внешний сервис однозначно подтвердил приём;
  • rejected — получатель явно отказал, и причина записана;
  • unknown — ответа нет, исход неясен, нужна сверка или решение человека.

Обратите внимание на различие между publishing и unknown. Первое — нормальное короткое состояние, пока Flow работает. Второе — результат того, что Flow не дождался ответа и честно признал: он не знает. Эта граница понадобится нам дальше, потому что именно на ней ломается большинство самодельных автоматизаций.

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

Какое действие допустимо в каждом состоянии
СостояниеЧто можно сделать редакторуЧто делает автоматика
idleУтвердить материалНичего
approvedЖдать; отозвать утверждение, пока не началась отправкаБерёт материал в работу
publishingТолько смотретьОтправляет запрос и ждёт ответ
publishedПравить следующую версию, а не эту публикациюОтправляет уведомление, один раз
rejectedИсправить причину и запросить зановоНичего
unknownПринять решение: сверить, подтвердить вручную или повторитьНичего, пока не получит решение

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

Как Flow проходит путь от нажатия до внешнего запроса

Flow в Directus состоит из триггера, операций и цепочки данных между ними — так это описано в документации по Flows. Триггер определяет, что запускает цепочку, операции выполняют шаги, а результат каждой операции становится доступен следующим шагам. Это и есть тот самый data chain, на который мы будем опираться: решение на шаге пять принимается по тому, что вернул шаг три.

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

Первый шаг: проверить, что запуск вообще законен

Триггером нашего Flow служит изменение материала: значение publish_state стало approved. Но событие — это только сигнал, что «что-то изменилось». Оно не гарантирует, что мир сейчас именно такой, каким его описывает событие. Пока событие дошло до Flow, другой экземпляр уже мог начать работу. Поэтому первая операция цепочки — не отправка, а перечитывание материала и проверка условия: состояние всё ещё approved? Если нет — цепочка завершается без побочных эффектов.

Это простое правило, но оно меняет архитектуру: триггер у нас сообщает о намерении, а право действовать проверяется в самом Flow по актуальным данным.

Второй шаг: занять материал до обращения наружу

Если проверка пройдена, следующая операция записывает в материал publishing, дату начала попытки и ключ публикации. И только после этого цепочка обращается ко внешнему сервису. Порядок принципиален. Если сначала отправить запрос, а потом записать состояние, то при сбое между ними вы получите опубликованный материал со статусом «утверждён» — самое неприятное из возможных расхождений, потому что ничто в данных не подсказывает, что работа уже сделана.

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

Третий шаг: ключ публикации

Ключ публикации — это значение, которое создаётся один раз в момент утверждения и не меняется при повторах той же публикации. В материале появляется поле publish_key; заполняется оно на шаге «занять материал», только если пустое. Если ключ уже есть, Flow его не перезаписывает.

Тут легко ошибиться, поэтому оговорю: сам по себе новый идентификатор каждой попытки ничего не делает для идемпотентности. Если Flow при каждом запуске порождает свежий случайный идентификатор, получатель видит каждую попытку как новую и обработает её как новую. Ключ работает только в двух случаях: он стабилен между повторами и получатель действительно умеет по нему распознавать дубли. Умеет ли ваш внешний API принимать такой ключ — вопрос к его документации, а не к Directus. Если не умеет, ключ всё равно полезен: по нему вы сможете спросить у сервиса, существует ли уже такая запись, до того как повторять отправку.

Четвёртый шаг: внешний запрос и разбор ответа

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

Тайм-аут и отказ — разные события

Самая частая смысловая ошибка в таких автоматизациях — сваливать всё неудачное в одну ветку «ошибка». Для человека «не получилось» звучит единообразно. Для системы «получатель ответил отказом» и «получатель не ответил вовремя» — это принципиально разные сведения о мире.

Если получатель ответил отказом, вы знаете главное: он увидел запрос и сказал «нет». Публикации на той стороне нет (разумно предположить, что отказ означает именно это, но поведение API стоит уточнить). Можно записать причину, перевести материал в rejected и вернуть редактору понятное сообщение: не прошла валидация заголовка, не хватает обязательного поля, токен истёк.

Если ответа нет, вы не знаете ничего. Возможны как минимум три мира:

  1. запрос не дошёл до получателя;
  2. запрос дошёл, получатель его отверг, но ответ потерялся;
  3. запрос дошёл, публикация создана, ответ потерялся или пришёл слишком поздно.

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

Три исхода внешнего запроса
Что произошлоЧто известноСостояние материалаСледующий шаг
Успешный ответ получателяПубликация принятаpublishedУведомление, один раз
Явный отказ с причинойПолучатель запрос видел и отклонилrejectedИсправление причины редактором
Нет ответа за отведённое времяНеизвестно, создана ли публикацияunknownСверка или решение человека

Сколько ждать и что считать «временем»

Предельное ожидание — решение, которое принимает не Directus, а вы вместе с владельцем внешнего сервиса. Если вы выберете слишком короткое, будете часто попадать в unknown там, где получатель просто чуть медленнее обычного. Слишком длинное — заставите Flow и редактора висеть в publishing. Подтверждённой документацией в этом материале я не располагаю по значениям таймаута операции и по тому, можно ли его настраивать в вашей версии, поэтому проверьте это в интерфейсе и в описании операции. Если ограничения по времени на самой операции нет, придётся опираться на ограничения получателя или промежуточного прокси — и тогда состояние publishing может подвисать дольше. Для этого случая полезно хранить время начала попытки: отдельная периодическая проверка (второй Flow по расписанию) может переводить слишком давние publishing в unknown.

Как выйти из «неизвестно»

Состояние unknown — не тупик, а вопрос, который нужно задать получателю другим способом. Есть три пути, и выбор зависит от возможностей внешнего API:

  • Сверка по ключу. Если сервис умеет отвечать на вопрос «есть ли у вас запись с таким ключом», Flow сверки отправляет такой запрос. Найдено — переводим в published. Не найдено — материал можно вернуть в approved на повторную отправку с тем же ключом.
  • Ручная проверка. Редактор или администратор заходит во внешний сервис, смотрит, есть ли публикация, и фиксирует решение в материале.
  • Повтор с тем же ключом. Допустим только если получатель документированно распознаёт ключ и отдаёт предсказуемый ответ при дубле. Без этого условия повтор — это ставка на удачу.

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

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.

АРЕНДОВАТЬ СЕРВЕР

Повторное нажатие: второе событие не должно сделать второе необратимое действие

Теперь вернёмся к исходной сцене. Редактор нажал дважды. Либо в системе образуются два события на одном материале, либо одно событие и один живой Flow, пока интерфейс ещё показывает прежнее состояние. Как схема это переживёт?

Первый заслон: состояние в данных

Второй запуск Flow начинает с проверки из первого шага. Если к этому моменту первый уже записал publishing, условие «состояние равно approved» не выполнится, и цепочка тихо завершится. Это самая дешёвая защита, и в большинстве случаев её достаточно.

Но у неё есть изъян, который стоит признать. Проверка «прочитать, потом записать» — это два отдельных действия. Если два события придут практически одновременно, оба могут успеть прочитать approved, прежде чем кто-то из них запишет publishing. Условная операция внутри Flow сама по себе не превращает эти два шага в неделимый.

Второй заслон: уникальность на уровне базы

Чтобы закрыть гонку, нужно переложить решение «кто первый» на механизм, который умеет это делать надёжно, — на ограничение уникальности в базе данных. Идея такая: заведите отдельную коллекцию попыток публикации, например publish_jobs, где пара «материал + версия материала» уникальна. Первая операция Flow — создать запись попытки. Если запись уже есть, создание не удастся, и это сигнал для цепочки остановиться. Два одновременных события дойдут до создания записи, но уникальность гарантирует, что успешной будет только одна.

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

Третий заслон: версия материала

Есть ещё один вид «повтора», который не про двойной клик. Редактор утвердил, потом заметил опечатку, поправил текст и утвердил снова. Это законная вторая публикация, а не дубль. Чтобы Flow отличал одно от другого, в запись попытки включают номер версии или хэш содержимого на момент утверждения. Тогда уникальность «материал + версия» разрешает вторую публикацию после правки и запрещает вторую отправку той же самой версии.

Необратимое действие — только одно

Отдельно о том, что именно считается необратимым. Публикация на внешней витрине — одно из них. Уведомление подписчикам — другое, и оно куда неприятнее: письмо или сообщение отозвать нельзя. Поэтому уведомление выделяют в отдельный шаг после состояния published и ставят на нём собственный флаг, например notified_at. Правило простое: уведомление отправляется, только если материал в published и поле notified_at пусто. Сразу после успешной отправки поле заполняется. Если повторное событие снова приведёт Flow в эту точку, он увидит заполненное поле и остановится.

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

Ручное исправление и автоматическое продолжение: кто владеет состоянием

Самая тонкая часть начинается, когда в процесс вмешивается человек. Материал завис в unknown. Редактор зашёл во внешний сервис, увидел, что публикация там есть, и хочет просто отметить её как опубликованную. В это же время второй Flow, проверяющий зависшие попытки по расписанию, решает проверить тот же материал и отправить повтор. Два участника, у каждого своя картина мира. Как сделать, чтобы они не спорили?

Один владелец в каждый момент

Принцип такой: у каждого состояния есть ровно один актор, который имеет право его менять. Пока материал в publishing, им владеет автоматика. Как только он попадает в unknown, автоматика отдаёт управление человеку и перестаёт трогать материал. Любое продолжение теперь начинается с явного решения.

Практически это выражается в отдельном поле решения, например resolution, с вариантами:

  • confirm_published — редактор проверил и подтверждает, что публикация есть;
  • retry — редактор подтверждает, что публикации нет и можно отправлять заново;
  • abandon — публикация не нужна, отзываем запрос.

Flow продолжения запускается именно изменением этого поля, а не изменением основного состояния. Так автоматика реагирует на осознанное решение, а не на случайную правку соседнего значения. И, как и в первом Flow, первым шагом он перечитывает материал и проверяет: состояние всё ещё unknown? Если, пока редактор раздумывал, периодический Flow уже перевёл материал куда-то ещё, решение уже неактуально, и Flow не должен его применять вслепую.

Что делает каждое решение

При confirm_published Flow записывает published, помечает источник подтверждения («вручную») и переходит к уведомлению по тем же правилам, что и в обычном пути. При retry Flow возвращает материал в approved, сохраняя прежний ключ публикации, и обычная цепочка начинает работу заново — с тем же заслоном по уникальности. Здесь важно, что повтор проходит через тот же вход, что и первая попытка, а не через отдельную «обходную» дорожку: меньше путей — меньше расхождений. При abandon материал возвращается в idle, а в журнал попыток пишется, кто и когда принял решение.

Заметьте, что retry — это не «повторить как получится», а решение человека, взявшего ответственность за то, что дубль не появится. Для внешних API, не умеющих распознавать ключ, это единственный честный способ.

Права на решение

В документации Directus отдельно указано, что Flows способны запускать код с повышенными полномочиями, а значит, доступ к их созданию и запуску важен. Для нашей схемы это означает две вещи. Во-первых, редактор не должен иметь права менять сами Flow — ему достаточно права менять поле решения в конкретной коллекции. Во-вторых, подумайте, кто вправе выбирать retry: возможно, не каждый редактор, а только старший или дежурный. Это вопрос политики вашей редакции, и лучше решить его заранее, чем разбирать после первого дубля.

Что остаётся в журнале и сколько это стоит на диске

Журнал Flow — это ваш главный инструмент разбора. По документации, он может хранить исходные данные запуска и сведения о правах (accountability), а также результаты операций. Уровень отслеживания активности и логов настраивается, и включённые логи занимают место в базе данных. Из этого вытекает практическое правило.

Для публикации со вложенным текстом статьи payload может быть заметным по размеру, а запусков много. Если включить полное сохранение на все Flow, база начнёт расти быстрее самих материалов. Разумный компромисс: полный след оставить на Flow публикации и решения — именно там нужна доказательная база, что произошло и по чьему действию, — а на вспомогательных Flow ограничиться минимальным уровнем. Конкретные цифры роста вам даст только наблюдение за вашей установкой, поэтому через неделю работы посмотрите размер таблиц и решите, нужно ли менять уровень или сроки хранения.

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

Где всё это живёт

Directus с базой данных и журналами нужна машина, на которой вы управляете окружением: можете ограничить доступ, настроить переменные окружения, наблюдать за диском. Подойдёт VPS с root-доступом. У MAATRIX это NVMe-накопители и процессоры AMD EPYC, а размещение возможно, по публичному предложению, в UK, US, RU и FR; доступность конкретных образов и локаций уточняйте в форме заказа. Тарифная линейка идёт от Ba (1 vCPU, 2 ГБ RAM, 40 ГБ NVMe) до Djed (16 vCPU, 32 ГБ, 320 ГБ), но эти цифры описывают ресурсы, а не гарантированную ёмкость: сколько публикаций в час выдержит ваша установка, покажет только замер на вашем контенте. Для базы, где копятся журналы, имеет смысл с самого начала присматривать за свободным местом. Порядок первичной установки описан в статье как установить и настроить Directus на VPS.

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

Как проверить схему до первой настоящей публикации

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

  1. Счастливый путь. Утвердить материал, получить успех, убедиться, что состояние published и уведомление ушло один раз.
  2. Явный отказ. Пусть тестовая точка возвращает ошибку с причиной. Состояние должно стать rejected, причина — видна редактору, уведомления нет.
  3. Молчание. Пусть точка не отвечает дольше принятого ожидания. Состояние должно стать unknown, а не rejected, и автоматика на этом остановиться.
  4. Двойное нажатие. Утвердить дважды подряд, затем попробовать два быстрых запуска одновременно. Тестовая точка должна получить ровно один запрос, а вторая попытка — оставить запись об остановке.
  5. Решение человека. Из unknown выбрать confirm_published, затем отдельным прогоном retry, и убедиться, что уведомление уходит один раз, а ключ публикации не меняется.
  6. Правка после утверждения. Изменить текст и утвердить заново: новая версия должна пройти, старая — остаться заблокированной.

Для каждого сценария смотрите не на интерфейс, а на три места: поле состояния, коллекцию попыток и журнал Flow. Если три источника рассказывают одну и ту же историю, схема работает. Если расходятся — вы только что нашли именно ту щель, которая иначе проявилась бы в пятницу вечером, а нам обещано без пятниц.

Карточка редактора

Всё сказанное нужно превратить в то, что редактор увидит над материалом. Три сообщения, не больше, и каждое отвечает на вопрос «что теперь».

  • Публикация завершена. Внешний сервис подтвердил приём, уведомление отправлено. Действий не требуется; править можно только следующую версию материала.
  • Ждёт продолжения. Материал утверждён или отправляется. Подождите: система работает сама и повторное нажатие ничего не ускорит. Если сообщение держится дольше обычного, оно само сменится на третье.
  • Требует решения. Ответа от внешнего сервиса нет, и система не знает, создана ли публикация. Проверьте внешний сервис и выберите: подтвердить публикацию, отправить заново или отказаться.

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

Если цепочка построена так, редактору больше не нужно угадывать, нажимать ли кнопку второй раз. Кнопка либо недоступна, либо означает осознанное решение. А ответ на вопрос из начала статьи — «что именно уже случилось?» — система даёт одним словом, которое читается без инструкции: готово, ждём или решайте.