Перейти к содержимому
6 мин чтения

Зачем долгим вызовам инструментов нужны lease и heartbeat?

Lease и heartbeat для долгих вызовов инструментов: как выдавать право воркеру, продлевать его и безопасно переживать зависания.

Зачем долгим вызовам инструментов нужны lease и heartbeat?

Долгий вызов инструмента нельзя считать живым только потому, что в базе стоит статус running. Этот статус быстро превращается в мусор: воркер упал, завис на сети, ушел в паузу рантайма или получил ответ от внешнего API, но не успел сохранить результат. Нужна схема, в которой воркер временно получает право исполнять задачу и регулярно подтверждает это право.

Lease и heartbeat решают две разные задачи. Lease не дает второму воркеру забрать работу слишком рано. Heartbeat позволяет короткому lease пережить нормальную долгую операцию. Но ни один из них сам по себе не делает систему exactly-once. Для безопасных побочных эффектов потребуются идемпотентность и fencing token.

Это особенно заметно в агентных системах. Модель может вызвать поиск, браузер, ERP, OCR, генерацию отчета или внутренний API. Один tool call заканчивается за секунды, другой ждет внешний сервис несколько минут. Фиксированный таймаут в такой очереди почти всегда выбран неправильно: слишком короткий создает параллельные дубли, слишком длинный заставляет команду ждать, пока очевидно мертвая задача снова станет доступна.

Lease дает временное право, а не обещание успеха

Lease - это запись о том, что конкретный воркер может исполнять задачу до определенного момента. После этого момента задача снова доступна для захвата. Воркер не владеет задачей навсегда и не получает исключительность по одному лишь owner_id.

Полезно хранить минимум пять полей:

create table tool_jobs (
  id uuid primary key,
  state text not null check (state in ('queued', 'running', 'succeeded', 'failed')),
  owner_id text,
  lease_until timestamptz,
  fencing_token bigint not null default 0,
  heartbeat_at timestamptz,
  attempt integer not null default 0,
  idempotency_key text not null unique,
  payload jsonb not null,
  result jsonb,
  error jsonb
);

owner_id отвечает на вопрос, кто держит lease сейчас. lease_until отвечает, когда это право перестанет действовать. fencing_token отделяет старого владельца от нового, если старый процесс очнулся слишком поздно. heartbeat_at нужен для наблюдаемости и расследований, но сам по себе не должен быть единственным критерием повторного захвата.

Не смешивайте lease со статусом. running описывает намерение и текущее состояние работы. Lease описывает право на владение. Задача может оставаться running, хотя ее прежний владелец уже потерял право, а новый еще не начал полезную работу. Это нормальное краткое состояние при передаче владения.

Документация etcd формулирует идею прямо: lease истекает, если кластер не получает keepalive в пределах TTL. У etcd это примитив хранилища, а не готовый протокол обработки вашей бизнес-задачи. Вам все равно нужно решить, что разрешено делать воркеру после истечения права и как приемник побочного эффекта отличит старого владельца от нового.

Статус running не отличает живой вызов от зависшего

Обычная поломка выглядит скучно. Воркер W1 берет задачу: «получи выписку, извлеки поля, передай их в систему согласования». Он ставит state = 'running', вызывает внешний сервис и зависает в библиотеке HTTP: соединение формально не закрыто, таймаут клиента не задан или DNS-запрос ждет дольше ожидаемого.

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

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

Например, браузерный инструмент может быть жив, посылать heartbeat и бесконечно ждать модальное окно. По heartbeat такая операция не зависла, но для продукта она уже не годится. Поэтому нужны два отдельных контроля:

  • lease и heartbeat для безопасности владения;
  • лимит длительности этапа и таймаут инструмента для качества выполнения.

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

Взятие задачи должно быть атомарным

Воркер не должен сначала прочитать свободную задачу, потом отдельно обновить ее. Между этими запросами другой воркер увидит то же состояние. Право на lease надо выдавать одной транзакцией и проверять время на стороне базы, а не на часах контейнера.

Ниже пример захвата для PostgreSQL. Он выбирает одну ожидающую или просроченную задачу, блокирует ее на время транзакции, назначает владельца, сдвигает срок lease и увеличивает fencing token.

with candidate as (
  select id
  from tool_jobs
  where state = 'queued'
     or (state = 'running' and lease_until < now())
  order by id
  for update skip locked
  limit 1
)
update tool_jobs j
set state = 'running',
    owner_id = :worker_id,
    lease_until = now() + interval '90 seconds',
    heartbeat_at = now(),
    fencing_token = fencing_token + 1,
    attempt = attempt + 1
from candidate
where j.id = candidate.id
returning j.id, j.payload, j.fencing_token, j.lease_until;

Типичный результат имеет такую форму:

id: 8f1b7c3d-...
fencing_token: 42
lease_until: 2026-07-23T21:14:30Z

for update skip locked здесь не магия и не универсальный рецепт. Он полезен, когда несколько воркеров читают одну таблицу и вам не нужен отдельный брокер. Он не заменяет ограничение параллелизма по пользователю, лимит на конкретный инструмент или приоритеты. Но он закрывает гонку, где десять воркеров выбирают одну строку queued до первого обновления.

Не применяйте время приложения в условии вроде lease_until < :client_now. Часы контейнеров расходятся, виртуальные машины могут резко скорректировать время, а один воркер может работать со старым временем после паузы. now() базы не делает распределенную систему идеальной, но дает одному арбитру единый момент принятия решения.

Heartbeat должен продлевать lease только текущему владельцу

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

Запрос продления должен выглядеть так:

update tool_jobs
set lease_until = now() + interval '90 seconds',
    heartbeat_at = now()
where id = :job_id
  and state = 'running'
  and owner_id = :worker_id
  and fencing_token = :fencing_token
  and lease_until > now()
returning lease_until;

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

Псевдокод цикла выглядит так:

job = claim(worker_id)
start heartbeat every 30 seconds

run tool with a bounded client timeout

on each heartbeat:
  if extend(job.id, worker_id, job.fencing_token) returns no row:
    cancel tool if the client supports cancellation
    mark local execution as lease_lost
    do not commit a success result

on tool completion:
  stop heartbeat
  commit result only if the same token still owns the job

Не делайте heartbeat в том же потоке, который выполняет вызов инструмента. Вызов может заблокировать event loop, занять единственный поток или зависнуть в нативной библиотеке. Отдельная задача рантайма, отдельный поток или отдельный процесс контроля обычно надежнее. Но этот контроллер не должен бездумно продлевать lease, если основной исполнитель уже завершился или стал недоступен для локального наблюдения.

Документация Amazon SQS предлагает именно этот принцип для непредсказуемой длительности: стартовать с короткой видимости и периодически продлевать ее, пока consumer работает. Там же прямо указано, что короткая видимость дает дубль до завершения первого consumer, а слишком длинная задерживает повтор после сбоя.

Интервалы выбирают по запасу времени, а не по красивому числу

Оставьте lease рядом с очередью
Подключите AI Router для моделей, не перенося heartbeat, идемпотентность и fencing token в gateway.

Схема «lease 90 секунд, heartbeat 30 секунд» часто годится для первого запуска, но не является правилом. Выберите длительность так, чтобы воркер успел пережить одну неудачную отправку heartbeat, краткий сбой базы и обычную паузу рантайма.

Практичная модель такая:

  • L - срок lease;
  • H - период heartbeat;
  • J - запас на джиттер сети, паузы процесса и перегрузку базы;
  • R - число подряд пропущенных heartbeat, которое вы готовы пережить.

Нужно, чтобы L > R × H + J. Если heartbeat идет раз в 30 секунд, вы хотите пережить одну пропущенную отправку и оцениваете запас в 20 секунд, lease на 90 секунд дает разумный буфер. Lease на 35 секунд в той же схеме означает, что обычная задержка может отнять право у еще работающего воркера.

Не пытайтесь продлевать lease за секунду до срока. Это популярная экономия запросов, которая ломается при первом же всплеске задержек. Продлевайте заранее, когда остается хотя бы один полный интервал heartbeat и резерв на неудачный запрос.

Разделяйте классы работ. Вызов классификатора с ожидаемой длительностью в несколько секунд не должен удерживать тот же lease, что и экспорт файла из учетной системы. Можно хранить lease_duration в типе задачи или передавать его в claim-операцию, но не давайте модели выбирать TTL напрямую. Модель может ошибиться, а иногда и получить инструкцию от пользователя выполнить бессмысленно долгую работу.

У SQS продление visibility timeout имеет еще один неприятный предел: максимум 12 часов отсчитывается от первого получения сообщения, а последующие продления его не сбрасывают. Это ограничение конкретного сервиса, но вывод полезен для любой архитектуры: бесконечно продлеваемая задача обычно должна стать последовательностью сохраняемых этапов.

Потеря lease требует остановки, даже если инструмент ответил успешно

Самый опасный момент наступает не при падении воркера, а когда старый воркер просыпается. W1 получил lease с token 41 и отправил запрос поставщику. Затем сеть между W1 и базой пропала. Heartbeat не проходит, lease истекает. W2 берет задачу, получает token 42 и выполняет ее заново.

После этого W1 получает запоздалый успешный ответ. Если он выполнит простой запрос:

update tool_jobs
set state = 'succeeded', result = :result
where id = :job_id;

он перезапишет результат нового владельца. Еще хуже, если W1 отправит второй платеж или подтвердит заявку во внешней системе. Статус в вашей таблице не остановит уже отправленный запрос.

Финальная запись должна быть условной:

update tool_jobs
set state = 'succeeded',
    result = :result,
    lease_until = null
where id = :job_id
  and state = 'running'
  and owner_id = :worker_id
  and fencing_token = :fencing_token
  and lease_until > now();

Если обновление не вернуло строку, результат нельзя объявлять принятым. Сохраните его в технический журнал для расследования, но не публикуйте пользователю и не используйте как состояние бизнес-процесса.

Здесь появляется fencing token. owner_id не годится как единственная защита: один и тот же воркер после рестарта может получить то же логическое имя, а старое сетевое соединение может продолжать жить. Монотонно растущий token связывает каждую выдачу lease с порядком владения. Приемник критичного побочного эффекта должен помнить последний принятый token и отвергать меньший.

Например, сервис списаний принимает заголовок X-Execution-Fence: 42. Если уже принята команда с token 42, команда с token 41 не имеет права менять состояние, даже если у нее корректная аутентификация. Это не делает внешний API идемпотентным автоматически, но устраняет класс ошибок «старый владелец дописал после нового».

Идемпотентность закрывает то, что lease закрыть не может

Обрабатывайте документы внутри страны
AI Router хостит модели с открытыми весами на собственной GPU-инфраструктуре для требований к хранению данных в стране.

Lease управляет конкуренцией до начала и во время работы. Он не может отозвать HTTP-запрос, который уже дошел до внешнего сервиса. Сеть способна оборвать ответ после того, как получатель выполнил действие. Воркер увидит таймаут и не поймет, повторять ли запрос.

Поэтому у каждой операции с побочным эффектом нужен идемпотентный ключ. Не генерируйте новый UUID на каждую попытку. Используйте стабильный ключ, который привязан к смыслу операции, например payment:{invoice_id}:capture или ticket:{job_id}:create.

Запрос к внешнему инструменту может иметь такую форму:

{
  "operation": "create_case",
  "idempotency_key": "tool-job:8f1b7c3d:create_case",
  "execution_fence": 42,
  "input": {
    "customer_id": "c-1048",
    "summary": "Проверить расхождение в счете"
  }
}

Получатель должен вернуть прежний результат, если увидел тот же idempotency_key второй раз. Если он поддерживает fencing token, он должен также отвергнуть запрос со значением меньше последнего принятого для той же сущности. Эти механизмы работают вместе:

  • идемпотентный ключ защищает от одинаковой команды, отправленной повторно;
  • fencing token защищает от старой команды, которая пришла после новой;
  • lease уменьшает вероятность того, что две попытки вообще начнутся одновременно.

Почти всегда популярный совет «поставьте ретраи» неполон. Ретраи без идемпотентности увеличивают вероятность дорогого дубликата. Ретраи без ограничения срока задачи создают бесконечную нагрузку. Ретраи без различения ошибок повторяют валидационные ошибки так же упорно, как временные сетевые сбои.

Для read-only инструментов риск ниже, но и там дубли вредят. Они тратят лимит API, могут вернуть данные из разных моментов времени и заставляют модель строить ответ на несогласованной картине. Внутри агентного контура это выглядит как «модель галлюцинирует», хотя причина часто в двух несинхронных выполнениях одной задачи.

Ошибки heartbeat нельзя считать обычными ошибками сети

Если один heartbeat не прошел, воркер еще может оставаться владельцем. Если он не знает, продлился ли lease, ситуация уже неоднозначна. Запрос мог дойти до базы, а ответ потеряться. Безопасная реакция зависит от оставшегося времени и типа операции.

Я использую простое правило: воркер перестает начинать новые внешние действия, когда не может подтвердить lease до консервативного дедлайна. Он может ждать повтор heartbeat, пока у него еще есть запас, но не должен запускать следующий шаг цепочки на последней секунде права.

Полезно разделить ошибки на три группы:

  • База ответила, что строка не обновлена. Lease потерян, работу надо остановить.
  • База недоступна или ответ не пришел. Владение неизвестно, нельзя выполнять новый необратимый шаг.
  • Внешний инструмент не ответил. Владение может быть сохранено, но результат внешнего действия неизвестен, поэтому нужен идемпотентный повтор или запрос статуса операции.

Состояние «владение неизвестно» часто пытаются спрятать за автоматическим ретраем. Не прячьте. Логируйте его отдельным событием с job_id, token, временем последнего подтвержденного lease и идентификатором внешнего запроса. Именно эти поля нужны в 02:00, когда оператор видит две заявки от одного пользователя и пытается понять, кто их создал.

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

Наблюдаемость должна показывать владение, а не только количество задач

Не меняйте клиентский код
OpenAI-совместимый API позволяет сохранить существующие SDK и промпты при подключении к AI Router.

График running jobs почти бесполезен. Он показывает, сколько строк в базе имеют статус, но не говорит, кто из них действительно владеет работой и насколько близок к истечению lease.

Минимальный набор метрик включает возраст последнего успешного heartbeat, остаток lease, число истекших lease, число неудачных попыток продления и количество задач, захваченных повторно. Разбейте их по типу инструмента, модели и внешнему провайдеру. Иначе всплеск таймаутов браузерного инструмента смешается с нормальными короткими вызовами поиска.

В журнале одного выполнения должны быть хотя бы такие события:

job_claimed job=8f1b... token=42 lease_until=21:14:30Z
heartbeat_ok job=8f1b... token=42 lease_until=21:15:00Z
tool_request_started job=8f1b... request=ext-791
heartbeat_lost job=8f1b... token=42 reason=zero_rows
result_rejected job=8f1b... token=42

Не записывайте в эти события полные промпты, документы и ответы инструментов по умолчанию. Для LLM-приложений журнал легко становится еще одним хранилищем персональных данных. Достаточно технических идентификаторов, хеша полезной нагрузки, класса инструмента, длительности и кода ошибки. Полное содержимое сохраняйте только там, где есть явная необходимость и политика хранения.

AI Router может быть удобным единым OpenAI-совместимым слоем для модельных вызовов, но lease должен оставаться в вашем контуре оркестрации, рядом с очередью и состоянием tool call. Шлюз моделей не должен решать, имеет ли воркер право повторно отправить платеж или изменить заявку.

Долгую агентную работу лучше дробить на сохраняемые этапы

Один огромный tool call с продлением lease на часы выглядит проще, пока не наступает первый сбой. После него вы не знаете, что уже сделал инструмент, где лежит промежуточный файл и можно ли безопасно продолжить.

Разбейте работу там, где появляется проверяемый результат. Например, процесс подготовки отчета можно представить так: получить исходные данные, сохранить нормализованный набор, построить черновик, отправить на проверку, опубликовать. Каждый этап получает собственный idempotency key, результат и новый lease. Между этапами система может остановиться без потери понимания, что сделано.

Не дробите ради дробления. Если этап длится 200 миллисекунд и не имеет отдельного побочного эффекта, новая задача добавит больше сложности, чем надежности. Граница нужна там, где операция долго ждет, обращается во внешний мир, дорого повторяется или должна быть проверена человеком.

Для LLM-агента особенно полезно отделять планирование от исполнения. Модель может предложить последовательность действий, но диспетчер должен создавать отдельные задания с разрешенным типом инструмента, лимитом времени, идемпотентным ключом и политикой повторов. Тогда потеря lease у одного вызова не превращает весь агентный запуск в неразбираемое состояние.

Сделайте один неприятный тест до продакшена. Пусть W1 захватит задачу и отправит внешний запрос. Затем остановите ему доступ к базе, дождитесь истечения lease, дайте W2 забрать ту же работу и только потом верните W1 сеть. Проверьте, что W1 не может продлить lease, не может записать результат и не создает второй необратимый эффект. Если этот тест не проходит, ваш heartbeat только рисует успокаивающие логи.

Часто задаваемые вопросы

Чем lease отличается от heartbeat?

Lease задает временное право воркера владеть задачей. Heartbeat регулярно доказывает, что воркер все еще исполняется, и продлевает это право. Отдельный lease без heartbeat либо слишком короток для долгой работы, либо слишком долго скрывает задачу после сбоя.

Гарантирует ли heartbeat, что задача не выполнится дважды?

Нет. Heartbeat доказывает только то, что воркер может связаться с координатором и еще считает себя владельцем. Перед необратимой операцией воркер должен проверить, что его lease и fencing token все еще актуальны.

Как часто отправлять heartbeat воркеру?

Практичный старт для многих долгих tool calls - lease на 60-120 секунд и heartbeat раз в 20-40 секунд. Затем подберите интервалы по p99 задержки записи heartbeat, паузам сборщика мусора и времени восстановления воркера. Не назначайте интервал вплотную к сроку lease.

Что должен сделать воркер, если потерял lease?

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

Нужен ли heartbeat для каждой фоновой задачи?

Для коротких CPU-задач или локальных операций с известным верхним пределом достаточно фиксированного таймаута. Для LLM-агентов, браузерных сессий, выгрузок файлов и вызовов внешних API длительность слишком меняется, поэтому heartbeat окупается быстро.

Зачем нужен fencing token, если есть owner_id?

Потому что сетевой раздел или пауза процесса могут оставить старого воркера живым после истечения его права. Новый владелец уже законно взял задачу. Монотонный fencing token позволяет приемнику отклонить запись от старого владельца.

Нужна ли идемпотентность при lease и heartbeat?

Да. Возврат воркера в очередь, истечение lease и повторная доставка должны считаться нормальными путями. Идемпотентность нужна и для финальной записи, и для внешнего вызова, если он меняет состояние вне вашей базы.

Что делать с операцией, которая идет много часов?

Разделите длинную работу на этапы с сохраняемым прогрессом, а не продлевайте один lease бесконечно. У SQS есть жесткий предел видимости в 12 часов от первого получения сообщения; даже если вы не используете SQS, такой предел дисциплинирует дизайн. (docs.aws.amazon.com)

Какие метрики показывают зависшие задачи?

Сигнал тревоги нужен, когда возраст последнего heartbeat превышает допустимый интервал или задача держится дольше ожидаемого класса работы. Но не лечите это автоматическим повтором без проверки lease: мониторинг должен различать зависание, очередь и медленный, но живой вызов.

Как связать lease-схему с LLM-приложением?

Передавайте состояние задач, лимиты и трассировку через обычные API-вызовы, а модель используйте только для полезной работы. AI Router подходит как единый OpenAI-совместимый шлюз к моделям, но право воркера на конкретную задачу должны хранить и проверять ваши очередь и база.