Некоторое время назад я писал про успешную миграцию с IBM BPM на Camunda, и теперь наша жизнь полна счастья и приятных впечатлений. Camunda не разочаровала, и мы продолжаем дружбу с этим BPM-движком.
Но, увы, Camunda может преподносить и неприятные сюрпризы, из-за которых иногда получаются не самые очевидные результаты. В этой статье будет рассмотрен один кейс, который, несмотря на простоту, получился интересным и несколько более сложным, чем казался на первый взгляд.
Тренируемся на кошках
Для описания проблемы рассмотрим синтетический пример. Предположим, мы решили расширять клиентскую базу и нужно обслуживать котов и кошек. Каждого потенциального клиента следует проверить и, возможно, сразу что-то предложить.
Мы будем проверять благонадежность кандидата и возможные услуги, которые можем предложить ему. Проверка благонадежности и возможных услуг никак между собой не связаны — эти действия можно выполнять параллельно. Схематично на bpmn-диаграмме это будет выглядеть следующим образом:
Диаграмма 1. Схематичный процесс обслуживания пушистых
На диаграмме схематично показаны основные шаги, ветвящий (fork) и собирающий (join) шлюзы.
Таким значком изображается параллельный шлюз. Parallel Gateway — самый простой из шлюзов для построения параллельно выполняющейся части процесса.
Существует два типа параллельных шлюзов:
- fork — создает отдельный execution для каждой ветки;
- join — ожидает выполнения всех входящих executions.
Execution — represent a 'path of execution' in a process instance (из документации). То есть это поток исполнения процесса.
Теперь чуть усложним задачу. Проверять и искать услуги будем следующим образом: сначала проверяем состояние клиента, затем смотрим, какие услуги могут ему подойти, и делаем некую предобработку. Кроме того, клиенту могут подойти сразу несколько услуг, поэтому мы должны быть в состоянии предложить клиенту их все.
Так как мы работаем с пушистыми клиентами, то и услуги будут соответствующие: валерьянка, когтедралка, хозяйская подушка и другие полезные штуки.
Диаграмма 2. Уточненная диаграмма обслуживания пушистых клиентов
Новая версия процесса выглядит следующим образом. Процесс параллелится на проверку надежности и поиск возможных предложений. Поиск тоже распараллеливается. В данном случае будут выполняться те ветки, на которых будут выполнены соответствующие условия.
Для распараллеливания с условиями используется Inclusive Gateway, который обозначается таким значком:
Inclusive Gateway — параллельный шлюз с условиями на ветках. Выполняться будут ветки, на которых условия истинны.
Существует два типа шлюзов:
- fork — для каждой ветки с выполненным условием создается execution, который параллельно выполняется аналогично execution в Parallel Gateway;
- join, в отличие от Parallel Gateway, ожидает выполнения executions не всех веток, а только тех, на которых условие true.
Может случиться так, что выполненных проверок будет недостаточно и клиента придется проверить еще раз. Для этого добавим условие в конце всех проверок, которое может отправить на повторную проверку в самое начало:
Диаграмма 3. Финальная версия процесса, который должен работать
Получилось громоздко, но задачу процесс решает.
Что такое? Что случилось?
Тут начинают происходить странные вещи. Ветка проверки надежности отрабатывает и доходит до собирающего параллельного шлюза. Пока все идет нормально.
Вторая ветка проверяет материальное состояние, и в зависимости от результатов выполняются соответствующие таски. Далее процесс останавливается на собирающем Inclusive Gateway шлюзе и дальше не двигается. Если посмотреть в Coockpit (админка камунды), то executions будут висеть на собирающем Inclusive Gateway и Parallel Gateway.
Диаграмма 4. Зависший процесс обслуживания
Дело сделано. Можно сказать, что мы получили дедлок в процессе на Camunda. В данном случае он не имеет прямого отношения к дедлокам из теории параллельного программирования и взаимным блокировкам.
В поисках ̶п̶р̶и̶к̶л̶ю̶ч̶е̶н̶и̶й̶ ответа
Так как у меня не было достаточного понимания, что произошло и почему процесс остановился, решать проблему пришлось эмпирическим путем.
Возможно, нужна дефолтная ветка для Inclusive Gateway и без нее процесс не может нормально выполниться?
Странно, конечно, но пробуем добавить дефолтную ветку. Наличие дефолтной ветки — хорошая практика, так как иначе может не выполниться ни одно условие и тогда получим ошибку.
Диаграмма 5. Процесс обслуживания с веткой по умолчанию
Запускаем и получаем тот же результат — процесс остается висеть на собирающем шлюзе Inclusive Gateway.
Далее идет перебор всевозможных параметров, чтение документации, и это затягивается на полдня. На очередной попытке процесс неожиданно проходит злополучный гетвей. Нижняя ветка c Inclusive Gateway отработала в ситуации, когда в процессе поиска и отладки была удалена верхняя ветка с проверкой надежности клиента. То есть, когда процесс выродился только в нижнюю ветку c Inclusive Gateway, процесс завершился.
Диаграмма 6. Выродившийся процесс
Получается, что на Inclusive Gateway как-то влияет Parallel Gateway. Это странно, нелогично и так не должно быть.
Как такое возможно? Наверное, стоит еще раз перечитать теорию о том, как работает Parallel и Inclusive Gateway. Что должно произойти, чтобы join gateway всех собрал и процесс пошел дальше? В интернетах пишут, что каждый собирающий Inclusive Gateway (join) ждет, когда в него зайдет столько же, сколько и вышло из «ветвящего» (fork). Тут неожиданно встает еще один вопрос: как вообще этот счетчик работает?
Что ты такое? Как ты работаешь?
Эта проблема достойна пазлеров и интеллектуальных телешоу. Только в телешоу разрешают воспользоваться звонком другу. С другой стороны, я тоже могу попросить о помощи. Будем звонить нашему архитектору бизнес-процессов Денису.
— Денис, привет! Не подскажешь, как собирающий гетвей определяет, когда процессу пора двинуться дальше? Везде пишут: «Сколько вышло — столько и должно войти». Но как именно он это считает?
— Очень просто. Camunda считает число активных executions.
— Спасибо большое. Пока
Рассмотрим, что же произошло. Для этого еще раз вспомним начальную схему, которая получилась:
Диаграмма 7. Зависший процесс с веткой по умолчанию
Для простоты рассмотрим случай, когда все условия выполнились. Что же имеем на момент времени, когда три таска после этих условий выполнились?
Сколько активных executions? Три по нижней ветке и один по верхней, где мы проверяли надежность клиента. Camunda не волнует тот факт, что это вообще разные параллельные ветки. Интересует только число активных executions, которых четыре, а входящий inclusive gateway получил только три.
Исправляем
Чтобы исправить ситуацию, собирающий Gateway должен собрать разом все executions, и тогда, в теории, процесс двинется дальше. Попробуем вместо двух join gateways оставить один:
Диаграмма 8. Исправленная версия процесса
Увы, после правок процесс стал выглядеть, на мой взгляд, менее очевидно. Зато отработал как и планировалось изначально. На этом квест благополучно закончился, я смог запушить изменения и пойти домой.
Интересное только начинается
Когда я сел писать эту статью и придумывал пример процесса, на котором я смог бы описать этот кейс, меня ждало разочарование: процесс работал как надо и никакого deadlock’a не было.
Сначала я предположил, что версия Camunda в примере выше, чем в проекте, и в новой версии эту проблему уже исправили. Но понижение версии Camunda ничего не дало. Кстати, во всех примерах используется версия 7.8.0 — далеко не самая свежая, но принципиального значения это не имеет. Проблема также была проверена и воспроизведена на последней на данный момент версии — 7.13.
Методом проб и ошибок была установлена проблема. Первоначальный искусственный пример не имел обратной ветки, в отличие от процесса, который я разрабатывал на рабочем месте.
Получается, что при наличии обратной ветки проблема воспроизводится и мы попадаем в некое подобие дедлока, а без обратной ветки все работает как надо.
Кейс требовал понимания и разбора. Для этого пришлось посмотреть исходники Camunda BPM. Так как проблема была с Inclusive Gateway, казалось логичным искать ответ в классе, который отвечает за поведение этого элемента — InclusiveGatewayActivityBehavior. Запустив пару раз дебаг на обеих версиях процесса, я понял, как это работает.
Если непонятно — смотри sources!
Чтобы не устраивать унылое повествование, описание работы InclusiveGateway на основе исходников будет схематичным. Интересующая нас логика сосредоточена в методе execute, где для данного кейса наиболее ценным является метод activatesGateway. Как я понял, в нем происходит проверка, возможно ли пройти InclusiveGateway. Вызывается метод execute для каждого execution (для каждой выполняющейся ветки). В нашем случае таких веток три — значит, этот метод будет вызван три раза.
Рассмотрим, как работает метод activatesGateway. Для лучшего понимания дадим имена всем выполняемым веткам.
Диаграмма 9. Диаграмма процесса с executions
Как я понимаю, логика метода следующая: происходит сравнение числа пришедших executions в этот гетвей и число входящих в этот гетвей стрелочек. Эта проверка сделана на случай самой простой ситуации, когда выполняются все ветки Inclusive Gateway, и логика проверки собирающего шлюза — дождаться, когда число вошедших executions будет равно числу входящих стрелочек. То есть в самом простом случае метод execute вызывается столько же раз, сколько веточек входит в собирающий шлюз, затем процесс идет дальше.
В нашем кейсе этот метод вызывается три раза, потому что число пришедших executions увеличится с 1 до 3. При последнем вызове число пришедших и ожидаемых будет 3 и 4 соответственно, и мы уйдем по false-ветке.
При невыполнении условия проверяются оставшиеся executions на принадлежность к Inclusive Gateway. А именно проверяется возможность активных executions добраться до join Inclusive Gateway.
Тут надо немного потерпеть, выдохнуть и дочитать. Развязка близка!
В false-ветке метода activatesGateway при каждом вызове еще не пришедшие в Inclusive join executions проверяются на возможность дойти до этого join. Если хотя бы один execution может привести в Inclusive Gateway, нужно его учитывать и ждать, когда он тоже придет в этот join. Если нет executions, которые могут привести в join, — метод вернет true.
Наступает самое интересное. На первый взгляд, последний execution (на схеме — execution 1) никак не может привести в Inclusive Gateway. Но стоит посмотреть на реализацию метода canReachActivity, который занимается этой проверкой, и станет понятна причина такого поведения этого элемента.
Если отбросить все детали кода, то внутри этого метода рекурсивно вызывается метод isReachable, который шаг за шагом проверяет возможность из этого execution попасть в собирающий InclusiveGateway. Обратная ветка как раз дает такую возможность, и, увы, это учитывается, хотя не должно, так как обратно мы пойдем уже после всех join'ов.
Как итог — Inclusive Gateway ждет еще одного execution, который никогда не придет. Таким образом, получаем некое подобие deadlock. В принципе, если отбросить условности, получается классический дедлок: join на Parallel ждет, когда выполнится ветка с Inclusive, и, наоборот ветка с Inclusive ждет, когда выполнится Parallel.
На схеме ниже представлено примерное направление проверки доступности join'а Inclusive Gateway из execution, который по параллельной ветке пришел в join Prallel Gateway.
Диаграмма 10. Возможный путь от Parallel Join до Inclusive Join
На cхеме видно, что, действительно, из join'а Parallel Gateway доступен join Inclusive Gateway и по логике Camunda BPM неважно, что тут уже идет «опережение на круг».
После выяснения причин невольно встал вопрос: это баг или фича? На мой взгляд, это баг. Сейчас я собираю информацию и кейсы, чтобы отправить отчет команде Camunda.
Хорошо, что проблема локализована. Но как быть сейчас?
Собственно, теперь — выводы:
- Предупрежден — значит вооружен. Надо строить свои процессы, учитывая такое поведение Camunda.
- Есть воркэраунд, описанный выше. Можно использовать общий parallel join.
- Можно вынести логику с Inclusive Gateway в подпроцесс, и такой проблемы не будет, так как проверка на executions идет внутри конкретного процесса.
- Не использовать вложенные цепочки гетвеев, а по возможности стараться обойтись более простыми конструкциями. Например, использовать Parallel Gateway и проверять условия на параллельных ветках.
Кажущаяся простота и понятность иногда бывают обманчивыми. Бороться с этим можно только путем накопления и тиражирования знаний. Увы, на момент решения этой задачи я не обладал глубоким знанием логики работы Inclusive Join, поэтому мне пришлось повозиться. Я обрел это знание методом проб, ошибок, звонком другу и дебагом исходников.
Из всего этого следует очевидный и далеко не новый вывод о том, что нужно понимать, как работает инструмент, который используешь. Чем лучше понимаешь, тем меньше будет подобных проблем.
Второй вывод тоже вполне очевидный: нужно декомпозировать не только код, но и процессы.
Ссылки, которые были полезны при разборе этого кейса и написании статьи: