Воркер отправил документ модели, получил ответ за 42 секунды и умер до записи результата. Очередь честно выдала задачу снова. Второй вызов стоил ещё $0,18 и вернул немного другой JSON. При тысяче таких документов сетевой сбой превращается в заметный счёт и спор о том, какой результат считать настоящим.
Exactly once для внешнего LLM-вызова недостижим: ваша БД и API провайдера не участвуют в общей транзакции. Реалистичная цель состоит в том, чтобы принимать задачу многократно, повторять внешний вызов как можно реже и фиксировать каждый эффект.
Ключи для разных уровней
request_id связывает логи одного запуска. idempotency_key выражает одно намерение клиента. dedup_key показывает, что два задания имеют одинаковый вычислительный вход. Не заменяйте их одним UUID.
import hashlib, json
def dedup_key(*, document_hash: str, pipeline: str,
prompt_version: str, model_profile: str) -> str:
payload = {
"document_hash": document_hash,
"pipeline": pipeline,
"prompt_version": prompt_version,
"model_profile": model_profile,
}
encoded = json.dumps(payload, sort_keys=True).encode()
return hashlib.sha256(encoded).hexdigest()Из ключа нельзя выбрасывать версию prompt-а или профиль модели: после релиза старый результат может быть технически доступен, но уже не соответствует текущему вычислению.
Состояние и атомарный захват
create table llm_job (
id uuid primary key,
idempotency_key text unique not null,
dedup_key text not null,
status text not null,
lease_until timestamptz,
attempt int not null default 0,
output jsonb,
error_code text,
updated_at timestamptz not null default now()
);
create unique index one_success_per_input
on llm_job(dedup_key) where status = 'succeeded';Воркер захватывает задачу короткой транзакцией, выставляя lease. Долгий сетевой вызов нельзя держать внутри транзакции с блокировкой строки. Если воркер умер, другой заберёт задачу после lease_until. Перед вызовом он снова проверит, нет ли готового результата по dedup_key.
Сохранение промежуточного результата уменьшает область повтора:
uploaded -> text_extracted -> chunks_ready -> llm_done -> validated -> publishedДля каждого перехода храните входной хеш, версию кода, статус, попытку и результат. Тогда смена валидатора требует повторить проверку, но не дорогую генерацию.
Retry и неопределённый исход
После 429 или 503 можно повторить запрос с backoff и jitter. После client timeout исход неизвестен: провайдер мог закончить генерацию и списать деньги. Если провайдер поддерживает свой idempotency key или получение результата по request ID, используйте его. Если нет, помечайте попытку outcome_unknown и применяйте явную политику: повторить для интерактивной операции либо дождаться сверки для дорогого batch.
for attempt in range(3):
try:
return await call_provider(timeout=45)
except RateLimited as exc:
await sleep(exc.retry_after or jittered_backoff(attempt))
except InvalidRequest:
raise
raise TransientFailure("retry budget exhausted")Retry budget ограничивается и числом попыток, и общим deadline. Три попытки по 45 секунд не помещаются в HTTP-запрос длиной 60 секунд, как их ни называть.
Failure modes
Слишком широкий dedup объединяет документы разных клиентов и может раскрыть результат. Включайте tenant и права доступа либо кэшируйте только внутри границы клиента. Слишком узкий ключ не находит дублей из-за timestamp в payload. Отделяйте содержательные поля от транспортных.
Публикация результата создаёт второй side effect. Запись succeeded и отправка события должны проходить через transactional outbox, иначе между ними останется окно: результат сохранён, событие потеряно или отправлено дважды. Получатель события тоже обязан быть идемпотентным.
Правило главы
Проектируйте повторное выполнение как штатный сценарий. Сначала определите границы намерения и вычисления, затем храните checkpoints и только после этого включайте автоматические ретраи.
Чеклист главы
- Idempotency и dedup имеют разные ключи и область действия.
- Версии prompt-а, модели и входной хеш входят в ключ вычисления.
- Долгий вызов выполняется вне транзакции, задача защищена lease.
- Неопределённый исход timeout учитывается отдельно.
- Retry ограничен попытками, deadline и бюджетом стоимости.
- Публикация результата использует outbox.
Практикум
Задача 16.1. Воркер с контролируемыми дублями. Реализуйте job store и обработчик, затем убивайте процесс после ответа модели, до записи и после записи результата.
Критерии приёмки:
- Повтор клиентского POST возвращает прежний
job_id. - Два конкурентных воркера не публикуют два результата.
- После смены версии prompt-а создаётся новое вычисление.
- Timeout записывается как неопределённый исход, а не как доказанный отказ.
- Событие о готовности доставляется как минимум один раз и применяется получателем один раз.