04.09 в 09:06:45 UTC ночной опрос Fleet нашёл одного «нового» курьера. Дальше автоматика отработала без единой ошибки: new_ids: 1, dispatch_alerted: 1, sms_sent: 1. В Google Sheet появилась строка 1248, в служебную группу пришёл алерт, человеку ушла SMS с просьбой подтвердить налоговый статус.
Проблема была в том, что этот курьер не был чужим. За две минуты до SMS оператор закончил создавать его вручную и уже сообщил: «аккаунт готов».
Это неприятный класс ошибок: каждый отдельный узел зелёный, HTTP-запросы отвечают 200, счётчики сходятся. Ошибается не транспорт, а признак, по которому система решила, кому принадлежит запись.
Что у нас считалось «своим» курьером
Есть два независимых процесса.
Первый принимает анкету и пытается создать профиль курьера автоматически. После успешного создания он вызывает внутренний webhook dispatch-mark-our и передаёт ID нового профиля. Опрос Fleet хранит эти ID в staticData workflow:
store.our_contractor_ids[contractorId] = Date.now();store.seen_ids[contractorId] = Date.now();
Второй процесс периодически забирает список из Fleet. Упрощённо старый предикат выглядел так:
const isOurPark = Boolean(store.our_contractor_ids[id]);if (!isOurPark && !store.seen_ids[id]) { newIds.push(id);}
Логика казалась надёжной: если профиль создала наша автоматика, его ID заранее попадёт в our_contractor_ids; если в следующем опросе появился неизвестный ID, значит запись пришла извне и её надо проверить на высадку.
Так работало для самозанятых, которых интеграция умеет создавать целиком. На ИП схема разошлась на две ветки.
Где потерялось происхождение записи
Execution 970462 вернул из процесса создания статус escalated_ip. Это не ошибка. Для ИП профиль создаёт оператор: автоматизация собирает данные, останавливается в предусмотренной точке и передаёт работу человеку.
Но webhook с отметкой «наш» стоял только после автоматического успеха. При escalated_ip его не вызывали. Проверка журнала за следующие пять часов подтвердила это буквально: запросов к /dispatch-mark-our не было ни одного.
Получилась гонка не по времени, а по идентичности:
09:04 оператор создаёт профиль в Fleet09:04 человеку уходит «аккаунт готов»09:06 опрос видит новый contractor_id09:06 contractor_id отсутствует в our_contractor_ids09:06 запись классифицируется как чужая09:06 строка 1248 + алерт + SMS
ID возникает слишком поздно. До ручного создания профиля автоматика его не знает, а после создания ближайший опрос может успеть раньше любого дополнительного действия оператора.
Сначала я хотел просто добавить отметку ID в ручную инструкцию. Это плохой фикс. Он превращает системную гарантию в ещё один пункт, который человек должен помнить в момент работы. Кроме того, чтобы отметить ID, его надо сначала скопировать из Fleet. Именно это окно между созданием и отметкой уже один раз и сработало.
Стабильный ключ был известен раньше
Номер телефона известен ещё на входе в процесс, до развилки между автоматическим созданием и передачей оператору. В списке Fleet он тоже есть. Поэтому мы добавили второй набор признаков — our_phones.
В обработчике отметки вход теперь читается и из body, и из query. Номер приводится к последним десяти цифрам:
const input = $json.body || $json.query || $json;const phone10 = String(input.phone || '').replace(/\D/g, '').slice(-10);if (phone10) { store.our_phones[phone10] = Date.now(); store.seen_phones[phone10] = Date.now();}
Чтение query добавили не для красоты. Основной machine-to-machine вызов остаётся POST с авторизацией заголовком. Но у служебного алерта появилась кнопка «Это наш». Telegram-кнопка открывает URL и не умеет приложить наш заголовок авторизации, поэтому для ручного подтверждения нужен отдельный GET-endpoint с непубличным токеном в пути.
В опросе Fleet признак стал составным:
const phone10 = String(contractor.phone || '') .replace(/\D/g, '') .slice(-10);const isOurPark = Boolean( store.our_contractor_ids[id] || (phone10 && store.our_phones[phone10]));
А ветка escalated_ip теперь отправляет телефон в dispatch-mark-our сразу, ещё до появления профиля в Fleet:
if (result.status === 'escalated_ip') { await markAsOurs({ phone: applicant.phone });}
Когда оператор через несколько минут создаёт профиль, опрос сопоставляет его не только с ещё неизвестным ранее ID, но и с уже отмеченным телефоном. ID остаётся основным точным ключом для автоматической ветки; телефон закрывает промежуток ручной ветки.
Что не сработало
Первый тупик — искать сбой доставки. SMS действительно ушла, строка действительно появилась, алерт действительно отправился. Повторная проверка API ничего бы не изменила: система исполнила ошибочное решение идеально.
Второй тупик — считать escalated_ip нештатной редкостью. Статус был предусмотрен кодом, но его трактовали как конец ответственности автоматики: дальше работает оператор. На деле ответственность не закончилась. Соседний процесс продолжал наблюдать тот же объект и не знал, что произошла легальная ручная передача.
Третий вариант, который отбросили, — предварительно резервировать будущий ID. Fleet назначает ID при создании профиля; получить его заранее нельзя. Генерировать собственный временный идентификатор тоже бессмысленно: опрос внешней системы его не увидит.
Наконец, нельзя было просто пометить номер в seen_phones и больше ничего не менять. seen отвечает на вопрос «мы это уже обрабатывали», а our — «эта запись принадлежит нашему процессу». Если смешать эти состояния, диагностика следующего инцидента снова упрётся в один непрозрачный флаг.
Как проверяли, что фикс не декоративный
Перед изменением сохранили полные JSON двух workflow. Затем вынесли код изменённых нод в стенд. Девять сценариев прошли, в том числе:
-
POST с телефоном записывает его в
our_phonesиseen_phones; -
GET-кнопка читает
query, а не толькоbody; -
новый профиль с неизвестным ID, но известным телефоном не попадает в высадку;
-
неизвестны и ID, и телефон — запись по-прежнему считается новой;
-
старый путь по
our_contractor_idsпродолжает работать.
Одних зелёных тестов было мало. Мы сделали три мутации: убрали чтение query, убрали запись телефона и вернули старый предикат только по ID. Во всех трёх случаях соответствующий тест покраснел. Старая версия ноды не прошла стенд.
После node --check оба workflow обновились с HTTP 200. Это тоже не считали результатом. Сразу проверили, что staticData пережил обновление: seen_ids содержал 3248 записи, our_contractor_ids — 441, bootstrap_done остался true. Потеря этого состояния запустила бы повторный bootstrap и создала отдельную аварию.
Последней была живая проверка GET-кнопки на том самом номере. Endpoint вернул:
{"ok":true,"marked_phone":"***","our_phones_count":1}
После этого запись присутствовала в реальном staticData, а не только в тестовом объекте. Ошибочную SMS, которая ещё находилась в очереди шлюза, отменили; её состояние перешло в Cancelling.
Замер до и после
|
Проверка |
До |
После |
|---|---|---|
|
Ключей, по которым узнаём свою запись |
1: ID |
2: ID или телефон |
|
Вызов отметки на ветке |
0 |
1 |
|
Сценариев стенда |
старая версия падает |
9 из 9 |
|
Мутаций, которые ловит стенд |
— |
3 из 3 |
|
Ложных объектов в инциденте |
1 из 1 новых |
0 после живой отметки |
Главная ошибка была не в том, что мы выбрали телефон как второй ключ. Ошибка появилась раньше: происхождение записи хранилось как побочный эффект только одной успешной ветки. Как только объект разрешили создавать человеку, contractor_id перестал быть достаточным доказательством происхождения.
Теперь ручная передача явно оставляет след до того, как внешний объект существует. А кнопка в алерте нужна для третьего случая — когда профиль создали совсем вне бота. Она не заменяет автоматическую отметку и не участвует в нормальном пути, зато даёт оператору способ исправить классификацию без доступа к данным workflow.
ссылка на оригинал статьи https://habr.com/ru/articles/1078708/