На этой странице
В докладе команды Crusoe по работе с разработчиками, опубликованном AI Engineer 3 октября 2026 года, на двух узлах A100 запустили задачу обучения PyTorch. Затем системе мониторинга самого демо сообщили, что один GPU выдал ошибку XID 79. Узел пометили как недоступный, а задачу отменили. Процесс получил SIGTERM и «до двух минут, чтобы обработать этот SIGTERM и либо сохранить контрольные точки, либо сбросить логи». Подключился запасной узел, и «задача slurm перезапустится. Она загрузит контрольную точку и продолжит обучение с того места, где остановилась». Чуть меньше пятнадцати минут от начала до конца. Никого не будили вызовом.
Синхронизация намного меньше по масштабу, чем обучение модели. Вопрос тот же. Что нужно запуску, чтобы гибель рабочего процесса стоила минут и ни одного дубликата?
Вот ответ для собственного исполнителя синхронизаций Tajo, прочитанный в коде, а не в брошюре. Статья про Reverse ETL описывает форму этой работы. Здесь о том, что происходит, когда работа умирает.
Проследим за одним контактом
Запуск читает источник по одной странице, начиная с сохранённого курсора. Назовём курсор страницы c3, а наш контакт пусть будет 212-й записью на этой странице. До того как что-либо записано, про этот контакт верны три вещи.
Во-первых, у него есть ключ доставки. Ключ представляет собой хеш идентификатора синхронизации, источника, курсора страницы, позиции на странице и самой записи. Курсор и запись входят в хеш и никогда не встраиваются в ключ, поэтому ключ не содержит адреса электронной почты.
Во-вторых, запись проходит через реестр, а не идёт прямо в целевую систему. Перед вызовом целевой системы ключ занимается со статусом «в ожидании». Когда запись проходит успешно, строка помечается как завершённая и хранится семь дней.
В-третьих, курсор не сдвинулся. Он сдвигается только в конце пакета, только если каждая запись этого пакета дошла, и только после того, как запуск доказал, что синхронизация всё ещё принадлежит ему.
Теперь убьём рабочий процесс между записью и контрольной точкой.
По SIGTERM каждый рабочий процесс очереди завершает текущую работу в пределах одного общего пятнадцатисекундного срока, а процесс выходит через двадцать пять секунд, что бы ещё ни выполнялось; systemd даёт ему сорок пять, прежде чем убить. Запуск, не завершившийся в этом окне, по-прежнему помечен как выполняющийся, и следующий рабочий процесс подхватывает его через механизм очереди для восстановления зависших задач. При жёстком убийстве никакого завершения нет вовсе. В обоих случаях верны те же два факта. В целевой системе наш контакт есть. Наш курсор по-прежнему показывает c3.
Повтор
Следующий запуск начинается с c3, читает ту же страницу, выводит тот же ключ для 212-й записи и спрашивает реестр. Реестр отвечает: завершено. Целевая система не вызывается; сохранённый результат воспроизводится, и запуск идёт дальше. На пакетном пути, где эндпоинт импорта Brevo принимает целую страницу за один вызов, та же проверка выполняется по каждому ключу до того, как собран запрос. Страница, в которой каждый ключ уже завершён, ничего не отправляет.
В этом весь фокус, и стоит сказать, почему он работает. Ключ выводится из курсора страницы в начале пакета, а не из запуска, который случайно выполнялся. Сбой, повторная попытка через шестьдесят секунд и плановый запуск завтра выводят один и тот же ключ для одной и той же записи. Реестру всё равно, кто из них спрашивает.
Теперь цена. Реестр забывает через семь дней. Собственный комментарий в коде говорит, что семь дней покрывают отложенную повторную попытку и короткий простой, и больше он ничего не утверждает. Повтор с курсора старше этого срока запишет снова. А запуск, прерванный жёстко, может ждать. Аренда мёртвого запуска держится до пятнадцати минут, а в плановой синхронизации запуск, чья аренда свободна, подбирается и перепланируется, только если он начался больше часа назад. Демо Crusoe возвращается меньше чем за пятнадцать минут. Наш запуск не всегда.
Кому принадлежит синхронизация во время перезапуска
В каждый момент синхронизацией владеет один запуск. Запуск берёт аренду в Redis со своим токеном, сроком жизни пятнадцать минут и продлением каждые пять минут. Перед каждой контрольной точкой он проверяет, что всё ещё держит эту аренду. Если нет, он останавливается. Запуск записывается простой фразой, которую прочитает человек: «The run lost its slot part-way through and stopped to avoid writing twice.» (запуск потерял свой слот на полпути и остановился, чтобы не записать дважды). Второй запуск, который сталкивается с живым владельцем, повторно не запускается. Он завершается сообщением «Another run of this sync was already in progress» (другой запуск этой синхронизации уже выполнялся), а следующий плановый запуск стартует как обычно.
Ответственность за исправление тоже записана. Каждый код ошибки сопоставлен с одной фразой о том, что произошло, одной о том, что делать дальше, и с владельцем: вы или Tajo. Целевая система, отклонившая каждую запись из-за неверного сопоставления полей, на вашей стороне. Потерянная аренда на нашей. Страница запуска показывает следующий шаг; руководство по восстановлению синхронизации указывает, какой из случаев.
Пока это лёгкая половина. Сбой стоит одного повтора, и ничего не попадает дважды. Другой половине научили два инцидента.
Сбой за сбоем
Одна синхронизация падала снова и снова. Не в плотном цикле: раз за интервал планировщика, запуск за запуском, каждый раз с одним и тем же сообщением о том, что пользовательский идентификатор не может содержать двоеточие. Запуск ни разу не дошёл до целевой системы. Он умирал при постановке в очередь, прежде чем его увидел какой-либо исполнитель, поэтому оповещению, привязанному к исполнителю, не на что было реагировать.
Серию никто не отслеживал. В этом весь вывод.
Исправление представляет собой предохранитель. После каждого записанного сбоя он читает последние пять завершённых запусков синхронизации с момента её последнего изменения. Если все пять завершились ошибкой, он приостанавливает синхронизацию, пишет одну строку аудита с причиной circuit_breaker_tripped и отправляет одно критическое оповещение. Серия выводится из сохранённых запусков, а не из счётчика в рабочем процессе. Считается сбой, записанный любым путём, включая сбой постановки в очередь, который и вызвал инцидент. Комментарий в коде говорит, что он остановил бы тот инцидент на пятом запуске. Один успех обнуляет серию. Снятие паузы снова взводит предохранитель, а не заставляет его повторно сработать на старых строках. Руководство по коннектору Shopify говорит то же самое одной строкой: после пяти неудачных запусков подряд синхронизация приостанавливается сама, а на её странице видна последняя ошибка.
Чего предохранитель не делает, так это ничего не исправляет. Crusoe заменяет узел и перезапускает задачу, ни о чём не спрашивая. Tajo останавливается и спрашивает: приостановленная синхронизация остаётся на паузе, пока человек не устранит причину и не опубликует её снова. Осторожное поведение по умолчанию выбрано намеренно: автоматический повтор синхронизации, которая пишет в записи клиентов, сам по себе является записью.
Тишина, которую никто не заметил
Возражение справедливо и звучит так: «Предохранитель, который ставит на паузу после пяти сбоев, поймал бы и второй инцидент. Зачем строить второго сторожа?» Потому что второй инцидент не выглядел как сбой. Долгое время в одном рабочем пространстве была активная синхронизация, растущая куча строк с ошибками и ни одного успеха, никогда, и ничто не подняло оповещение. Каждый существующий сигнал был привязан к активности: запуск упал, пришёл вебхук, превышен бюджет. Ноль успехов не является событием. Это отсутствие события, а предохранитель срабатывает только тогда, когда записан сбой. Мёртвый планировщик, заклинившая очередь, рабочий процесс, который так и не стартовал: всё это ничего не записывает, и предохранитель так и не просыпается.
Рукоятка бдительности машиниста (dead man’s handle) работает наоборот. Машинист должен постоянно её нажимать, и тишина останавливает поезд. Версия для синхронизации запускается раз в час, в тринадцать минут каждого часа. Для каждого рабочего пространства с активной синхронизацией она задаёт один вопрос: завершился ли успешно хоть один запуск за последние двадцать четыре часа? Рабочее пространство, чья самая старая активная синхронизация включена меньше суток назад, остаётся в покое; у него ещё не было суток, чтобы добиться успеха. То, что старше и молчит, получает одно критическое оповещение. Оповещение говорит, о какой тишине речь: запуски записаны, но ни один не успешен, или запусков не записано вовсе. Оно срабатывает один раз на инцидент. Маркер в Redis держит его закрытым до первого успешного запуска, затем снимается, так что следующий инцидент снова вызывает оповещение.
Оба сторожа появились в одном коммите, задним числом. Комментарий предохранителя говорит, что он остановил бы первый инцидент на пятом запуске. Комментарий контроля бдительности говорит, что синхронизация, включённая давно и не имеющая ни одного запуска, должна вызывать оповещение. Ни одного из них не было, когда они были нужны. Мы написали их потом, записав оба инцидента в заголовке файла, чтобы никто не забыл зачем.
Что построено, а что нет
Построено на момент написания:
- Курсор сохраняется в конце каждого чистого пакета, а также при сбое.
- Ключ доставки каждой записи выводится из курсора страницы и её позиции и сверяется с семидневным реестром перед каждым вызовом целевой системы.
- Одна аренда на синхронизацию, со сроком жизни пятнадцать минут.
- Пять попыток на запуск, с нарастающей паузой начиная с шестидесяти секунд.
- Предохранитель, который приостанавливает синхронизацию после пяти сбоев подряд и записывает причину.
- Ежечасная проверка с оповещением о двадцати четырёх часах без успеха.
Не построено, и это стоит знать, прежде чем на это полагаться:
- Контроль бдительности следит за рабочим пространством, а не за синхронизацией. Одна здоровая синхронизация скрывает молчащую соседнюю в том же рабочем пространстве.
- Его оповещение доходит до операторов Tajo: всегда как строка в логе, а также через Sentry, вебхук или электронное письмо там, где это настроено. На странице синхронизации оно не появляется. Пауза от предохранителя появляется.
- Ничто не перезапускает приостановленную синхронизацию. Это делает человек.
- Восстановление после жёсткого убийства небыстрое. Аренда может держаться пятнадцать минут, а очистка ждёт час.
- Оба порога, пять сбоев и двадцать четыре часа, являются настройками развёртывания. Задать их для отдельной синхронизации нельзя.
Длинная цепочка почти идеальных шагов всё равно даёт сбой; выход в том, чтобы определить конечное состояние и проверять его. Эта статья представляет собой эксплуатационную половину того же принципа. Определите, что оставляет после себя мёртвый запуск, и проверяйте то ничто, которое оставляет после себя тишина.
Заключительная фраза Crusoe звучала так: «сбои неизбежны». Наша на шаг дальше. Сбой был лёгкой половиной, потому что оставил строки. Тишина была той половиной, которую нам пришлось искать.
Часто Задаваемые Вопросы
Что происходит с идущей синхронизацией, когда рабочий процесс падает посреди пакета?
Курсор по-прежнему указывает на начало прерванного пакета, потому что Tajo сдвигает его только после того, как пакет чисто записан. Следующий запуск снова читает ту же страницу. Каждая запись несёт ключ доставки, выведенный из курсора страницы и её позиции, а реестр хранит каждую завершённую запись семь дней, поэтому уже записанные данные воспроизводятся из реестра, а не отправляются в целевую систему второй раз.
Почему повторная синхронизация не записывает один и тот же контакт дважды?
Перед каждым вызовом целевой системы запуск сверяет ключ доставки записи с реестром. Ключ представляет собой хеш синхронизации, источника, курсора страницы, позиции на странице и самой записи, поэтому сбой, повторная попытка через минуту и плановый запуск на следующий день выводят один и тот же ключ для одной и той же записи. Ключ, уже отмеченный как завершённый в пределах семидневного окна, повторно не отправляется.
Что такое контроль бдительности (dead man) для синхронизации?
Проверка, которая срабатывает на отсутствие, а не на сбой. Раз в час Tajo просматривает каждое рабочее пространство с активной синхронизацией и поднимает оповещение, если за последние двадцать четыре часа ни один запуск не завершился успешно, включая случай, когда не было записано ни одного запуска. Она срабатывает один раз на инцидент и снова взводится после первого успешного запуска. Она существует потому, что оповещение о сбое видит только те сбои, которые были записаны.