Представьте обработчик, который получает уведомление из amoCRM о том, что сделка перешла на этап «Договор». Разработчик рассуждал просто: раз событие пришло, значит, надо подготовить договор. Обработчик поднимает данные сделки, подтягивает контакт и компанию, подставляет реквизиты в шаблон, собирает PDF, кладёт файл в хранилище и только после этого отвечает «200 OK». В тестовой среде это занимает пару секунд, и все довольны.
В рабочем аккаунте менеджер переводит сделку на этап, ничего не видит и через десять секунд сдвигает карточку обратно, а потом снова вперёд: вдруг не сработало. Генератор, который ещё не закончил первую попытку, получает вторую. Потом третью. Шаблонизатор шумит, внешний сервис печати отвечает медленнее обычного, и обработчик уже занят так долго, что новые уведомления встают в очередь на уровне веб-сервера. В итоге у клиента лежат три договора с разными номерами, а в карточке сделки одна примечание «Документ готов», причём ссылка в нём ведёт не на тот файл.
Ошибка здесь не в генераторе документов и не в amoCRM. Ошибка в том, что одно короткое HTTP-соединение попросили выполнить роль приёмной, цеха и архива сразу. Дальше разберём, как эти роли развести, что обязательно записать в момент приёма и как сделать так, чтобы повторы и перестановки событий не превращались в дубликаты договоров. Точных лимитов времени ожидания у amoCRM мы здесь называть не будем: их нужно смотреть в актуальной документации вашего аккаунта, а проектировать лучше так, чтобы цифра не имела значения.
Три разные вещи, которые легко принять за одну
Слово «вебхук» в разговорах прячет три отдельных понятия, и путаница между ними порождает половину проблем.
Первое — подписка. Это настройка на стороне amoCRM: на какой адрес (destination) слать уведомления и о каких событиях. По документации amoCRM один вебхук может быть подписан сразу на несколько событий, у него есть адрес назначения и признак отключения, а API позволяет подписываться на события и отписываться от них (amoCRM: Вебхуки API). Подписка — это обещание «я хочу узнавать», а не обещание «я что-то сделаю».
Второе — доставка уведомления. Это отдельный HTTP-запрос к вашему адресу. Его единственная осмысленная задача для вашего сервиса — принять и подтвердить, что принято. Доставка может прийти дважды, может прийти не в том порядке, в каком случились события, может прийти пачкой. Проектировать приёмник разумно так, будто всё это возможно, даже если в спокойные дни не случается.
Третье — бизнес-задача: подготовить документ, сверить остатки, создать счёт, написать в мессенджер. Она медленная, зависит от внешних систем, может падать и требовать повторов. Между доставкой и задачей нужна долговечная запись, и именно она превращает хрупкую цепочку в управляемую.
Если держать эти три слоя раздельно, вопрос «что делать, когда генерация тормозит» сводится к простому ответу: приёмник на неё не смотрит. Он положил запись в таблицу и ответил. Worker берёт запись, когда ему удобно, и тормозит только он сам.
Что должна уместить минимальная запись
Соблазн записать в приёмнике «всё, что пришло» и «всё, что может понадобиться» велик, но нужно сопротивляться второму. Приёмник не должен ходить в API amoCRM за дополнительными данными: это снова тяжёлая работа с сетью внутри короткого запроса. Его бюджет — одна быстрая запись в локальную базу.
Что в эту запись входит:
- Сырое тело уведомления целиком, как оно пришло. Это страховка: если схема разбора изменится или вы найдёте ошибку в парсере, вы сможете переиграть событие без обращения к CRM.
- Идентификаторы, по которым строится дедупликация: аккаунт, тип сущности, её номер, тип события. Какие именно поля есть в теле уведомления для вашего события, смотрите на живом примере из тестового аккаунта, а не по памяти.
- Время приёма на вашей стороне. Время в данных CRM и время вашего приёма — разные вещи, и когда придётся разбираться с порядком, пригодятся обе.
- Тип задачи, которую это событие порождает. Для нашего примера — подготовка документа.
- Состояние и служебные поля для worker: число попыток, время следующей попытки, последняя ошибка.
Минимальная схема на PostgreSQL может выглядеть так:
CREATE TABLE crm_inbox (
id bigserial PRIMARY KEY,
dedup_key text NOT NULL,
account_ref text NOT NULL,
entity_type text NOT NULL,
entity_id bigint NOT NULL,
event_type text NOT NULL,
task_type text NOT NULL,
raw_body text NOT NULL,
received_at timestamptz NOT NULL DEFAULT now(),
status text NOT NULL DEFAULT 'new',
attempts int NOT NULL DEFAULT 0,
next_run_at timestamptz NOT NULL DEFAULT now(),
locked_until timestamptz,
last_error text,
UNIQUE (dedup_key)
);Заметьте, чего в ней нет: нет названия сделки, суммы, имени менеджера, полей контакта. Всё это worker возьмёт свежим из CRM в тот момент, когда будет готовить документ. Копия данных, снятая при приёме, к моменту выполнения может устареть, а договор с вчерашней суммой хуже, чем отсутствие договора.
Ключ дедупликации: почему пустая строка — не то же самое, что NULL
Ограничение уникальности в таблице — самый дешёвый способ не создавать вторую задачу из повторной доставки. Документация PostgreSQL описывает, что PRIMARY KEY задаёт уникальность и запрещает NULL, а UNIQUE может охватывать несколько столбцов сразу (PostgreSQL: Constraints). Тут есть два практических выбора.
Можно сделать составной UNIQUE по смысловым столбцам: аккаунт, тип сущности, идентификатор, тип события и что-то, что отличает одно срабатывание от другого. А можно, как в схеме выше, собрать из этих частей одну строку-ключ и положить уникальное ограничение на неё. Составной вариант прозрачнее при ручном разборе, строковый проще, когда состав ключа со временем меняется или различается для разных типов событий.
Тонкость в том, что составной ключ зависит от того, как вы записываете отсутствующие части. В той же документации сказано, что пустая строка не тождественна NULL. Для вас это значит следующее: если в одном обработчике отсутствующее поле превращается в NULL, а в другом в пустую строку, база будет считать такие записи разными, и дубликат тихо проскочит. Договоритесь в коде приёмника об единственном способе обозначать отсутствие — и лучше нормализовать его до явного значения вроде '-' ещё до формирования ключа, либо объявить такие столбцы NOT NULL и не допускать вопроса вовсе.
Теперь главный вопрос: а что именно считать «тем же самым событием»? Тут два крайних решения, и оба неверны.
- Ключ по хэшу тела. Он отсекает точные повторы доставки, но не отсекает «то же самое действие, пришедшее с чуть другим телом» — например, если в теле есть время отправки. Для защиты от двойного клика менеджера он бесполезен: два клика — это два разных события.
- Ключ только по сущности. Он схлопывает всё, что связано со сделкой, и вы потеряете смену этапа, которая действительно требует нового документа.
Рабочий компромисс обычно строят по смыслу бизнес-задачи. Для подготовки договора смысл такой: «для сделки N на этапе «Договор» нужен один документ определённой версии шаблона». Тогда ключ задачи — это сделка, тип задачи и признак версии (например, номер версии шаблона или идентификатор этапа). Повторная доставка и двойной клик на одном этапе дают один ключ и одну задачу. Возврат на этап через месяц с обновлёнными условиями — другая история, и её стоит решать осознанно, а не случайно: нужен ли новый документ или достаточно прежнего.
Приёмник при конфликте по ключу ничего страшного не делает. Он отвечает так же успешно, как и на новое событие, потому что с точки зрения отправителя доставка выполнена. Вставка с ON CONFLICT (dedup_key) DO NOTHING здесь естественна: дубликат — не ошибка, а штатная ситуация.
Приёмник: короткий путь от запроса до ответа
Вся логика приёмника укладывается в четыре шага: прочитать тело, проверить, что оно разбирается, вычислить ключ, вставить строку и ответить. Всё. Никаких походов в CRM, никакой генерации, никаких вызовов чужих сервисов.
Про проверку подлинности стоит сказать отдельно. Не стоит переносить механизм подписи из другой CRM: у разных систем разные схемы, и подпись, описанная для одной, в другой попросту не появится. Какие способы подтвердить источник уведомления есть в вашей редакции amoCRM и как они настраиваются, нужно читать в документации именно по ней. Пока этой информации нет, разумны дешёвые меры, которые не зависят от конкретики: нестандартный путь с длинным случайным компонентом в адресе destination, ограничение метода и размера тела, а главное — принцип, что любое событие является всего лишь намёком «что-то изменилось». Worker, как мы увидим, всё равно сверяется с источником данных, и поддельное уведомление может в худшем случае вызвать лишнюю проверку, а не подмену суммы в договоре.
Если вставка в базу не удалась, приёмник должен ответить ошибкой, а не «успешно». Это единственный случай, когда сбой честно сообщается отправителю: событие не сохранено, значит, его нельзя считать принятым. Всё остальное, включая невозможность разобрать поле, лучше сохранить как есть со статусом, который покажет проблему, чем потерять.
Worker и задача подготовки документа
Теперь разберём исполнителя на конкретной задаче. Пусть у нас тип задачи prepare_contract. Что она делает, по шагам:
- Захватывает строку: выбирает запись со статусом «новая» или «повтор», у которой
next_run_atнаступило, и ставитstatus = 'running'иlocked_untilв будущем. Захват должен быть атомарным, чтобы два worker не взяли одну запись; в PostgreSQL для этого подходит выборка с блокировкой строк и пропуском занятых. - Читает актуальное состояние сделки из amoCRM: этап, сумму, привязанные контакт и компанию. Если сделка уже ушла с этапа «Договор», это повод пометить задачу как неактуальную, а не гнать договор по инерции.
- Проверяет в собственной таблице документов, нет ли уже результата для этого ключа.
- Если результата нет, рендерит документ и сохраняет файл под детерминированным именем, выведенным из ключа задачи.
- Записывает в таблицу документов номер, путь к файлу и внешние идентификаторы.
- Только потом отправляет результат обратно: примечание в сделку или уведомление менеджеру.
- Помечает задачу выполненной.
Порядок важен. Если worker упал между шагами 4 и 5, при повторе он снова начнёт с проверки и увидит файл с тем же именем. Если упал между 5 и 6, повтор найдёт готовый документ и просто повторит отправку примечания. Для этого шаги и построены так, чтобы каждый можно было безопасно проиграть второй раз.
Результат — отдельная сущность, а не флаг в очереди
Хочется положить результат в ту же строку очереди: поле document_url. Но очередь — это про процесс, а документ — про результат, и живут они по-разному. Задача через неделю будет удалена или заархивирована, а договор нужен годами. Поэтому у документа собственная таблица с собственным ограничением уникальности, например:
CREATE TABLE contract_documents (
id bigserial PRIMARY KEY,
lead_id bigint NOT NULL,
template_ver text NOT NULL,
doc_number text NOT NULL,
file_path text NOT NULL,
crm_note_ref text,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (lead_id, template_ver)
);Здесь UNIQUE по двум столбцам — снова тот самый составной случай из документации PostgreSQL. Он страхует от второй гонки: даже если два worker по ошибке начали одну сделку, вторая вставка получит конфликт, и худшее, что случится, — один из них зря потратит время на рендер.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.
Повторяемость: что значит «можно запустить ещё раз»
Повторы бывают намеренные (задача упала, и вы её перезапустили) и случайные (процесс умер на середине, и по истечении locked_until запись вернулась в оборот). Для вас разницы нет: задача должна сходиться к одному результату.
Тут легко принять желаемое за действительное, поэтому проговорим два ограничения.
Во-первых, транзакция вашей базы не охватывает внешний вызов. Если вы начали транзакцию, записали строку, вызвали API amoCRM и зафиксировали, то откат транзакции не отменит уже созданное примечание в CRM. Мы не вправе предполагать атомарность там, где её никто не обещал, поэтому и строим порядок шагов так, чтобы обрыв в любой точке оставлял состояние, которое повтор способен распознать и достроить.
Во-вторых, новый идентификатор попытки сам по себе ничего не делает идемпотентным. Бывает, что разработчик генерирует свежий UUID на каждый запуск задачи и считает это защитой от дублей. Но защита работает только тогда, когда идентификатор одинаков для всех повторов одной и той же логической работы. Если при каждом повторе создаётся новый, он просто маркирует новые дубликаты. Идентификатор должен выводиться из смысла задачи (сделка, тип документа, версия шаблона), а не из номера попытки. Номер попытки нужен для другого: чтобы считать, сколько раз уже пробовали, и увеличивать паузу.
Внешние идентификаторы: запоминайте то, что вернул мир
Каждый раз, когда worker создаёт что-то снаружи, он получает идентификатор: номер примечания, ссылку на файл в хранилище, номер счёта во внешней системе. Эти значения надо сохранять сразу, в той же строке результата. Причины практические:
- при повторе вы проверяете «у меня уже есть примечание N» вместо слепого создания второго;
- при разборе жалобы «менеджеру пришло два договора» вы идёте по цепочке: задача — документ — примечание, и видите, где она раздвоилась;
- когда документ нужно отозвать или заменить, вы знаете, на что именно ссылаться.
Если внешний вызов прошёл, а запись о его результате не успела сохраниться, остаётся окно неопределённости. Закрыть его целиком нельзя, но можно сузить: перед созданием вносить в результат намерение («создаю примечание для ключа K»), а при повторе, увидев намерение без идентификатора, сначала искать во внешней системе по признаку, который вы туда положили, например по метке в тексте примечания. Это защита по месту применения и она требует аккуратности, но лучше, чем надеяться на удачу.
Несколько событий об одной сделке
Теперь самое неприятное. Менеджер за минуту сменил этап, поправил сумму, добавил контакт и снова поправил сумму. Подписка на несколько событий, о которой говорит документация, включая и возможность подписать один адрес на разные типы, означает, что в вашу очередь могут лечь четыре-пять записей об одной сделке почти одновременно. Порядок их выполнения не гарантирован, потому что worker может быть не один.
Тут помогают три приёма, которые комбинируются.
Первый: событие — намёк, а правда — в источнике. Worker всегда читает текущее состояние сделки, а не опирается на содержимое уведомления. Тогда неважно, обработана ли «сумма 100» после «сумма 120»: оба задания увидят актуальную сумму, и результат будет одинаков. Это самый мощный приём, и он работает благодаря тому, что мы не копировали данные при приёме.
Второй: схлопывание. Если для одной сделки уже есть невыполненная задача того же типа, новое событие не должно создавать вторую рядом. Либо ключ дедупликации строится так, что они совпадают, либо worker при взятии задачи помечает все более ранние невыполненные записи того же ключа как поглощённые. Пользователь, нажавший три раза подряд, получает один документ.
Третий: последовательность по сущности. Для некоторых задач схлопывания недостаточно: например, «создать счёт» и «закрыть счёт» нельзя выполнять параллельно. Тогда правило взятия задачи расширяется: нельзя брать запись, если для того же entity_id уже есть запись в статусе «running». Параллелизм сохраняется между разными сделками, а внутри одной сделки всё идёт строго друг за другом.
Разберём на примере с условными цифрами. За одну минуту по сделке 4521 пришло пять записей: смена этапа на «Договор», изменение суммы, добавление контакта, ещё одно изменение суммы и повторная доставка первой. Ключ задачи prepare_contract строится из сделки и версии шаблона, поэтому повторная доставка схлопывается сразу при вставке. Изменения суммы и контакта не порождают задач подготовки договора вообще: для них могут существовать другие типы задач или они просто не нужны. В очереди остаётся одна запись, и когда worker до неё доходит, он читает сделку с последней суммой и последним контактом. Пять уведомлений, один документ.
А теперь усложним: менеджер через минуту поправил сумму ещё раз, когда документ уже сформирован. Нужен ли новый документ? Это не технический вопрос, а бизнес-правило, и у него два честных решения. Либо документ версионируется по сумме и условиям, тогда ключ включает версию, и появляется новая запись. Либо документ создаётся один раз, а изменения фиксируются дополнением. Главное — выбрать явно и отразить в ключе, а не позволить случаю решать.
Состояния, повторы и пауза между ними
Для очереди такого назначения хватает небольшого набора состояний: new, running, retry, done, superseded (поглощена более новой), dead (исчерпаны попытки или ошибка не подлежит повтору). Не стоит множить их без причины: каждое новое состояние — ещё один случай, который нужно обработать в каждом запросе к таблице.
Различайте ошибки по природе. Если шаблонизатор не нашёл обязательное поле реквизитов, повтор через минуту ничего не изменит: это дело человека, и задача должна сразу стать dead с понятным текстом в last_error, а менеджеру уйти сообщение. Если сервис хранилища ответил таймаутом, повтор осмыслен, и пауза между попытками должна расти, иначе вы будете долбить его в самый плохой момент. Растущая пауза — это та же идея, что и в разборе перегрузки очереди: про backpressure и рост очереди мы писали отдельно, и оттуда пригодится мысль, что очередь, которая принимает быстрее, чем обрабатывает, рано или поздно упирается в диск и в терпение людей. Следите за возрастом самой старой невыполненной записи: рост этого числа раньше всего показывает, что worker не успевает.
Если worker умер, не освободив запись, её возвращает в оборот срок locked_until. Этот срок должен быть заметно больше типичного времени выполнения задачи, иначе живой worker, всё ещё рендерящий большой документ, потеряет свою запись, и её подхватит второй. Для длинных задач разумно периодически продлевать срок, пока работа идёт. А ограничение уникальности на документ — последний рубеж на случай, если срок всё же был выбран неудачно.
Управление установленными вебхуками
Очередь описана, но остаётся вопрос, который часто забывают: сам вебхук тоже ресурс, за которым нужно следить. Документация amoCRM отмечает, что получение списка установленных вебхуков требует прав администратора аккаунта. Практическое следствие: инструмент, который проверяет, на месте ли ваша подписка, нельзя запускать с токеном обычного менеджера. Либо вы заводите отдельный служебный доступ с нужными правами, либо делаете проверку ручной процедурой для администратора.
Что именно проверять в списке? У вебхука есть адрес назначения (destination) и признак отключения (disabled), а также набор событий, на которые он подписан. Из этого получается три контрольных вопроса:
- указывает ли destination на действующий приёмник, а не на адрес стенда, оставшийся с разработки;
- не отключён ли вебхук (флаг disabled);
- не подписан ли он на события, которых ваш приёмник не ждёт, и не потеряна ли подписка на нужные.
Последний пункт заслуживает внимания. Раз один вебхук способен охватывать несколько событий, лишние подписки засоряют очередь работой, из которой вы не извлекаете пользы. Подписывайтесь на минимум, который реально порождает задачи, и записывайте рядом с кодом соответствие «событие — тип задачи». Через полгода это спасёт от вопроса, зачем вообще пришло это уведомление.
Что происходит, когда подписку отключают
Отключение или отписка — штатная операция при переезде, отладке или аварии. Но у неё есть цена, которую надо понимать заранее. Пока вебхук отключён или события не подписаны, уведомления до вашего приёмника не доходят, а значит, очередь не пополняется, хотя в CRM менеджеры продолжают работать. Очередь при этом выглядит здоровой: пустая, ошибок нет. Тишина здесь — симптом, а не признак порядка.
Как с этим обращаться, если вы сами отключаете подписку:
- Запишите время отключения и время включения.
- После включения выполните сверку: запросите из CRM сделки, изменённые за этот интервал, и для каждой создайте задачу через тот же путь вставки с тем же ключом дедупликации. Если для сделки уже была обработка, ключ отсечёт лишнее. Как именно запрашивать изменённые за период сущности и какие фильтры доступны, смотрите в документации API вашей редакции amoCRM.
- Только после сверки считайте интервал закрытым.
Та же сверка нужна, если ваш приёмник был недоступен: нельзя рассчитывать, что каждое пропущенное уведомление будет доставлено заново. Надёжность даёт не вера в доставку, а возможность спросить источник, что изменилось.
Безопасное переключение на новый приёмник
Допустим, вы переносите обработку со старого монолитного обработчика на описанную схему. Не меняйте destination у рабочей подписки одним движением. Разумнее так: сначала поднять новый приёмник и очередь, пропустить через них тестовые события, затем добавить подписку на новый адрес рядом со старой, когда оба обработчика видят одно и то же. Но тут легко получить двойную работу: старый делает договор по-старому, новый — по-новому. Поэтому на время параллельной работы новый worker должен выполнять задачи в режиме «только записать, что сделал бы», либо старый обработчик нужно заранее отключить для побочных действий. И лишь убедившись, что новая очередь видит всё то, что видел старый обработчик, отключите прежнюю подписку и сделайте сверку интервала переключения, как описано выше.
Где всё это живёт на VPS
Схема не требует сложной инфраструктуры: небольшой HTTP-приёмник, PostgreSQL с таблицами очереди и результатов, один-два процесса worker и каталог или хранилище для готовых файлов. На VPS MAATRIX всё это умещается на одной машине с root-доступом, где вы сами выбираете, чем принимать запросы, как запускать worker и как настраивать базу. Тарифы начинаются с конфигурации Ba (1 vCPU, 2 ГБ RAM, 40 ГБ NVMe) и идут через Uab (2 vCPU, 4 ГБ, 80 ГБ) и Heka (4 vCPU, 8 ГБ, 160 ГБ) до более крупных Maat и Djed. Ресурсы тарифа — это не обещание конкретного числа обработанных документов в минуту: сколько задач в час выдержит ваша связка, покажет только замер на вашем шаблоне и ваших данных.
Разумно мыслить так. Приёмник почти ничего не потребляет: разбор тела и одна вставка. Основные ресурсы забирает генерация документа: процессор при рендере и диск при записи файлов. Поэтому узким местом станет worker, а не приёмник, и масштабировать стоит число worker и их приоритет, а не приёмный слой. Если нагрузка от документов начнёт мешать самой базе очереди, это повод вынести рендер на отдельную машину, а не переписывать схему. Резервное копирование базы очереди и файлов при этом остаётся вашей задачей: в публичных данных о тарифах автоматические копии для всех конфигураций не заявлены, так что их нужно настраивать самостоятельно.
Отдельная роль у NVMe-накопителя: очередь на таблице в PostgreSQL — это поток мелких записей (вставки, смены статуса, удаление старых строк), и быстрый диск здесь естественный союзник. Но и тут без замера не стоит делать выводов о предельной нагрузке.
Итоговая картина одного договора
Теперь соберём всё вместе на той самой сцене из начала. Менеджер переводит сделку на этап «Договор». amoCRM отправляет уведомление на ваш destination. Приёмник читает тело, строит ключ из сделки, типа задачи и версии шаблона, вставляет строку и отвечает. Это занимает время одной вставки в локальную базу, а не времени печати документа.
Менеджер, не увидев результата, снова двигает карточку. Второе уведомление приходит, ключ тот же, вставка упирается в ограничение уникальности и тихо игнорируется. Ответ снова успешный.
Worker подбирает запись, захватывает её, читает актуальное состояние сделки, проверяет таблицу документов, рендерит PDF, сохраняет его под детерминированным именем, записывает номер и путь, затем создаёт примечание в сделке и сохраняет его идентификатор. Если процесс на любом шаге оборвётся, следующий запуск увидит, докуда дошла работа, и доделает остальное. Если генератор сегодня работает втрое медленнее обычного, очередь немного подрастёт, но входящие уведомления продолжат приниматься без задержки: приёмник и генерация теперь не стоят в одной очереди к одному соединению.
Вот и весь переход. Задержка генерации больше не удерживает обработчик входящих уведомлений: он занят приёмом, а договор готовится тогда, когда до него дойдёт очередь.