Отказоустойчивость и масштабируемость
Статус: принято. Связанные документы: failure-model.md (семантика сбоев и
доставки событий), failure-reconciliation.md (реконсиляция неопределённых
операций), supervisor-model.md (триаж и человеческий фактор).
Этот документ фиксирует, как ядро ведёт себя при сбоях инфраструктуры, какими ресурсами ограничен воркфлоу рана и почему приняты ключевые архитектурные решения вокруг Temporal — включая те, от которых осознанно отказались.
#1. Temporal как слой долговечности: replay коротко
Воркфлоу рана — event-sourced: каждый шаг (вызов модели, инструмента, запись события журнала) — отдельная активность, результат которой неизменяемо записан в историю воркфлоу. При падении воркера, деплое или перезапуске Temporal поднимает код воркфлоу заново и переигрывает историю: уже выполненные активности не запускаются повторно (ни LLM-токены, ни команды песочницы), их результаты подставляются из истории. Ран переживает рестарты воркера и деплои без потери прогресса — это фундамент отказоустойчивости, а не её нарушение.
Следствия, зафиксированные в коде:
- Детерминизм — дисциплина, а не пожелание. Любое поведенческое
изменение детерминированных путей
AgentRunгейтитсяworkflow.GetVersion(delegation-note-steering,model-schedule-to-start): старые истории переигрываются тем кодом, против которого они записаны. Нарушение этого правила = воркфлоу-таска вечно падает на проверке детерминизма, ран выглядит замершим (реальный инцидент, см. git history). - Активности идемпотентны или под политикой. Модельные вызовы чистые
(промпт-кэш по
PromptCacheKey); инструменты с внешними эффектами несут operation id и reconciliation (failure-model.md,failure-reconciliation.md). - Отмена доходит через heartbeat:
kernel.call_modelбьётся heartbeat'ом, поэтому nudge/cancellation останавливает живой HTTP-стрим, а не оставляет зомби. - Тяжёлые payload'ы — в журнал, не в историю Temporal. Полные ответы
модели, выводы инструментов и события опыта пишутся
RecordEventактивностью в observation-БД. История воркфлоу держит компактные структуры — это осознанная дисциплина, от которой зависит и размер истории, и скорость replay (§2).
#2. История воркфлоу: границы и математика
Критика «кодинг-петля раздует Event History до гигантов и уложит харнес в лимит 50 000 событий» проверена против конфигурации и отвергнута — история рана ограничена конструкцией:
| Ограничитель | Значение |
|---|---|
| Бюджет ходов | DefaultMaxTurns = 8, потолок MaxTurnsCeiling = 100 |
| Событий Temporal на ход | ~20–60 (call_model + run_tool ×N + RecordEvent ×M) |
| История одного рана при 100 ходах | ~2–6 тыс. событий — в 8–25 раз ниже лимита |
| Делегации | Дочерние раны — отдельные воркфлоу: их история не раздувает родительскую |
| Глубина/ширина делегаций | MaxDelegationDepth, MaxDelegationWidth |
| Повторные сбои | квоты ранов (KERNEL_RUN_QUOTAS), кластеризация супервайзером (A2) |
Replay-цена при этих размерах — миллисекунды на workflow-task. «Секунды и минуты на промотку» начинаются на десятках тысяч событий с тяжёлыми payload'ами — куда мы их сознательно не кладём (§1, последний пункт).
#2.1. Continue-As-New: осознанно отложено
CAN (атомарный перезапуск воркфлоу с чистой историей) — стандартное лекарство Temporal, но в текущей конфигурации не нужен (границы выше) и дорог: воркфлоу держит append-only разговор (байт-стабильный промпт-кэш), steering-каналы заметок, привязки к задаче/саге/родителю. Перенос всего этого в новый воркфлоу — отдельный проектированный релиз с версионированием.
Триггеры возврата к теме: подъём MaxTurnsCeiling к сотням ходов;
появление ранов, у которых history_size_bytes устойчиво превышает
~10 МБ; прямой запрос на суточные автономные прогоны без делегаций.
#2.2. «Петлю отладки — внутрь одной активности»: отвергнуто
Упаковка «напиши код → прогони тесты» в одну долгоживущую активность сжала бы историю Temporal, но уничтожила бы продукт: пошаговую трассу, подтверждения посреди работы, nudge и steering-дополнения, триаж супервайзером по шагам — и добавила бы потерю 40-минутной работы при падении воркера (checkpoint'ов внутри активности нет). Пошаговость + replay — фича, а не накладные расходы. Отказ зафиксирован; пересмотр только вместе с §2.1.
#3. Наблюдаемость границ
- Живой статус рана —
DescribeWorkflowExecution(статус воркфлоу в Temporal, обогащение результата). - История подбирается к лимиту → предупреждающий бейдж на бюджетной
вкладке рана (
history_size_bytesиз describe) — ранний сигнал к завершению/разбиению задачи до принудительного terminate. - Сбои поставки событий в журнал видны как лаг/пропуски
(
failure-model.md, инвариант 5), а не как успешная тишина.
#3.1. Нагрузочное тестирование: пошаговая инструкция
Инструменты в репозитории; провайдеры не задействованы, всё локально и воспроизводимо:
examples/mockllm— детерминированный OpenAI-совместимый провайдер: стрим/блок, сценарий «2 тула → вопрос → финальный ответ», флаги первого токена, межтокенного интервала, инжекции 429 (-fail-every N), гистограмма первого токена в/metrics. В compose — под профилемloadtest.examples/loadgen— драйвер сценария: создаёт проект/агента/задачи, стартует N ранов с ramp'ом, доводит каждый до терминального статуса (включая ответы на HITL-паузы), печатает p50/p95/p99 и пишет markdown-отчёт;-cleanupудаляет созданные задачи после отчёта.scripts/loadrun.sh— оркестратор одной команды: preflight (сборка песочницы через setup API, доступность мока), сэмплер ресурсов (docker stats каждые 2с), прогон, сводка пиков CPU/памяти.
Перед кампанией: проверяйте контракт мока. Скрипт ассертит профиль токенов мока, но не формат его tool-payload: расхождение формата (
run_commandстрокой вместо массива) позволяет кампаниям «зелёно» завершаться, не исполняя компонент песочницы вовсе. Быстрая проверка перед прогоном:curl -s http://localhost:8081/v1/chat/completions -H 'Content-Type: application/json' \ -d '{"model":"loadgen-model","messages":[{"role":"user","content":"start"}], "tools":[{"type":"function","function":{"name":"run_command","parameters":{}}}],"stream":false}'Аргументы должны быть
{"command":["sh","-c","…"]}(см.fix(loadgen)).
#Подготовка стенда (однократно)
- Требования: Linux + Docker, Go (для сборки драйвера), свободный диск 50+ ГБ. Hyper-V/виртуалка: фиксированная память (не dynamic), swap 8 ГБ.
- Исходники и env:
git clone https://github.com/RuslanGabbasov/temporality.git && cd temporality
cp .env.example .env
- В
.envна время кампании:
TEMPORALITY_MODEL_BASE_URL=http://mockllm:8081/v1
TEMPORALITY_MODEL_ID=loadgen-model
# KERNEL_AUTH_TOKENS — закомментирован (иначе онбординг-режим недоступен)
# KERNEL_SANDBOX_IMAGE — закомментирован (иначе шаг сборки песочницы 409)
- Поднять стек с моком:
docker compose -f docker-compose.yml -f docker-compose.dev.yml --profile loadtest up -d
- Снапшот виртуалки — страховка и точка сравнения.
#Кампания
scripts/loadrun.sh 5 5 smoke-5.md # смоук
scripts/loadrun.sh 50 60 report-50.md # устойчивая нагрузка
scripts/loadrun.sh 100 120 report-100.md # расширенный (по памяти)
scripts/loadrun.sh 100 120 hitl-100.md --hitl # HITL: паузы по 30с у каждого рана
Preflight скрипта сам соберёт песочницу через setup API, проверит мок и
выпадет с ошибкой, если что-то не так. После отчёта задачи удаляются
(-cleanup по умолчанию); журнал остаётся append-only архивом профиля.
Профили и критерии: 50 / 100 / 200 параллельных ранов, каждый профиль — дважды (второй прогон ловит деградацию после первого); критерий приёмки — p95 длительности рана не деградирует между профилями более чем на шум, ошибок старта нет. Разброс p95 между повторами > 20% — стенд «шумит», выводы делать нельзя.
#Тяжёлый профиль (--heavy)
Лёгкий профиль меряет оркестрацию; тяжёлый — рабочую нагрузку: команды песочницы по 10с (десятки параллельных контейнеров), стримы по 1.5 с на токен (длинные живые стримы), ask_human-пауза в середине, 300 токенов на ответ. Запуск:
# env мока (compose loadtest-профиль пробрасывает их в контейнер):
export MOCKLLM_FIRST_TOKEN=1s MOCKLLM_TOKEN_GAP=1500ms MOCKLLM_TOKENS=300
export MOCKLLM_TOOL_COMMAND='sleep 10 && echo heavy' MOCKLLM_ASK_AFTER_TOOLS=2
scripts/loadrun.sh 100 120 heavy-100.md --heavy --hitl
Ожидания от тяжёлого профиля: одновременно 30–60 живых песочниц, сотни событий журнала в секунду, живые стримы во все раны; пики CPU несколько ядер — это норма, критерий деградации прежний (p95 не плывёт, хвостов нет, 100% завершение).
#Измеренный масштаб (v2026.2.3+, 2026-10-18)
Стенд: виртуальная машина Hyper-V (12 vCPU, 16 ГБ RAM); образы v2026.2.3, мок-провайдер (детерминированный сценарий «2 тула → финал», 200 мс до первого токена).
Профиль: 100 параллельных ранов (ramp 120с), max_turns 3:
| Метрика | Значение |
|---|---|
| Завершение | 100/100 completed, 0 сбоев старта |
| Длительность рана | p50 5057ms · p95 5070ms · p99 5076ms |
| Разброс p50→p99 | 0.3% — хвостов нет |
| Латентность старта (API) | p50 3ms · p95 5ms · p99 9ms |
| Пик CPU | ядро 107% (~1 ядро из 12) · postgres 67% · journal 60% |
| Пик памяти | ядро 987 MiB · postgres 89 MiB · journal 19 MiB |
Выводы: 100 параллельных ранов занимают ~1 ядро и ~1 ГБ памяти ядра; хвосты латентности отсутствуют. Типичный пик команды из 10 человек (10–30 параллельных ранов) — в 3–10 раз ниже протестированной точки. Масштабирование до сотен параллельных ранов подтверждено измерением.
#HITL-профиль
Паузы «человек в контуре» меняют характер нагрузки: воркфлоу висит в
ожидании, копятся approval-wait и HWT. С --hitl мок отвечает
ask_human после двух тулов, драйвер играет человека (-human-delay 30s),
находит human.requested в журнале и отвечает approval-сигналом с
operation id + arguments hash. В отчёт попадают доля ранов с паузами,
число отвеченных вопросов и средняя пауза (HWT) — первые измеренные
цифры для канбан-метрик из §5. Гипотеза профиля: сотни висящих ожиданий
не деградируют ни Temporal, ни память воркера, ни UI-очередь вопросов.
#Отчёт кампании
Каждый прогон фиксирует: конфигурацию стенда (vCPU/RAM/диск/версию
образов), профиль (раны/ramp/режим), таблицу перцентилей, статусы, пики
CPU/памяти по сервисам, сбои. Итоговая сводка кампании — раздел «Измеренный
масштаб» этого документа: заполняется по мере прогонов на стандартизированных
стендах.
события), учитывая -human-delay как эмуляцию реакции. В отчёт попадают
доля ранов с паузами, число отвеченных вопросов и средняя пауза (HWT) —
первые осмысленные цифры для канбан-метрик из §5. Профиль проверяет
именно масштабную гипотезу: сотни висящих ожиданий не должны деградировать
ни Temporal, ни память воркера, ни UI-очередь вопросов.
Смоук-прогон 5 ранов (мок 200ms до первого токена, 2 тула, local stack) — все завершились; полноценные профили гоняются на стенде с докером и фиксируются в этом разделе по мере накопления.
#4. Отказоустойчивость деплоя и эксплуатации
- Деплой на живом стенде: воркфлоу и активности переживают рестарт
воркера; новые попытки активностей подхватываются новым бинарем;
ScheduleToStartTimeout(5 мин) делает видимым случай, когда активности нечем выполняться, вместо молчаливого ожидания. - Зависший ран: мягкая отмена (cancel) обрабатывается воркфлоу;
принудительный terminate (
POST /v1/agent/runs/{id}/terminate) срабатывает всегда и журналирует терминальноеrun.cancelledсам — используется, когда воркфлоу неспособен обработать сигналы. - Свежая установка: до создания первого пользователя гейт аутентификации
выключен (анонимный admin для онбординга); сервисный принципал ядра
(
usr-temporality-kernel) — внутренний credential, не субъект гейта;KERNEL_AUTH_TOKENSвключает аутентификацию явно (вход настроенными токенами вместо анонимного онбординга). - Образы: мультиарх (linux/amd64 + linux/arm64), digest-pinned; деплой = pull закреплённой версии, миграции применяются ядром на старте.
#5. Сводная таблица: что ограничивает что
| Ресурс | Ограничитель | Где настроено |
|---|---|---|
| Ходы одного рана | 8 по умолчанию / 100 потолок | RunInput.MaxTurns, MaxTurnsCeiling |
| История Temporal | ~2–6 тыс. событий при потолке; лимит Temporal 50 тыс. | конструкция воркфлоу, §2 |
| Токены/стоимость рана | KERNEL_RUN_QUOTAS, модельные бюджеты |
миграция 000020, RunInput |
| Дерево делегаций | глубина MaxDelegationDepth, ширина MaxDelegationWidth |
workflow.go |
| Ожидание человека | таймауты approvals/ask_human | RunInput.ApprovalTimeoutSeconds |
| Событийный журнал | outbox + dedup по (source, event_id) | failure-model.md |
| Повторяющиеся сбои | кластеризация + предложения эволюции | supervisor A2/B2 |
| Безнадёжно зависший ран | terminate из UI | runs.stop |
#6. Измеренные результаты: heavy-профиль на 6 ядрах
Поправка (2026-10-27). Эта кампания гонялась с моком, чей payload
run_command({"command":"…"}— строка) не соответствовал контракту ядра, ужесточённому фичей песочницы (массив строк). Команды песочницы падали сInvalidToolArgumentsдо исполнения — компонента «два выполнения sleep 10 в песочнице» в цифрах ниже не исполнялась: ран держали стрим (375 × 1.5 с) и HITL-паузы (~46 с), а «песочниц одновременно 8» отражало контейнеры стенда, а не песочницы. Длительность рана и выводы о ёмкости оркестрации остаются честными, но тяжёлый профиль с реально работающей песочницей требует перезамера. Контракт починен вfix(loadgen); после мержа — перезамер по той же методологии.
Стенд: виртуальная машина Hyper-V (6 ядер, 32 ГБ); стек
целиком в docker compose (Postgres+pgvector, journal, kernel, debugger,
Temporal, searxng, lightpanda); провайдер — детерминированный mockllm
(examples/mockllm), все исходники и скрипты — в репозитории.
Профиль heavy (scripts/loadrun.sh --heavy --hitl): каждый ран — 4 модельных
хода, два выполнения sleep 10 в песочнице, пауза ask_human (~45 s
«раздумий» симулированного человека), финальный ответ — стрим 375 токенов
с паузой 1.5 s/токен (~9.4 мин непрерывного стрима). Итого ~10.2 мин
жизни рана, 9–10 обращений к провайдеру, 2 песочницы.
| Метрика | 25 ранов | 50 ранов | 100 ранов |
|---|---|---|---|
| Завершившихся | 25/25 | 50/50 | 100/100 |
| Длительность рана p50 | 614.9 s | 614.9 s | 615.0 s |
| p95 / p99 | 615.0 / 615.0 s | 619.9 / 620.1 s | 620.1 / 620.2 s |
| Старт-API p50 / p95 | 4 / 9 ms | 3 / 4 ms | 3 / 6 ms |
| HITL пауз отвечено | 25 (HWT 45.8 s) | 50 (47.3 s) | 100 (47.6 s) |
| Пик CPU kernel | 97% | 97% | 119% |
| Пик CPU journal | 147% | 223% | 316% |
| Пик CPU postgres | 93% | 80% | 113% |
| Пик RAM journal | 131 MB | 209 MB | 305 MB |
| Песочниц одновременно | 8 | 8 | 8 |
Ключевые выводы:
- Длительность рана константа (614.9 → 614.9 → 615.0 s): четырёхкратный рост параллелизма не стоил агентам ни миллисекунды — конвейер (Temporal → kernel → провайдер → журнал) не деградирует до сотни параллельных десяти-минутных сессий.
- Рост journal сублинейный (×2 ранов = ×1.4 CPU на переходе 50→100): события батчатся через durable outbox, поллинг потребителей ограничен.
- Суммарный пик ~5 ядер на сотне — 6-ядерный стенд в кондексе, но с запасом; memory-профиль плоский (сотни мегабайт на сервис).
- Инструментация (пер-роутные счётчики journal
JOURNAL_DEBUG_ADDR,pg_stat_statements, сэмплерloadrun.sh) — часть методологии: без атрибуции «кто ест CPU» цифры плавления бесполезны.
История итераций: первый прогон сотни (до фиксов) дал 0/100 терминальных ранов и перегруз журнала. Разбор по слоям нашёл и починил: неприменённый профиль мока (compose override + assert'ы), старт-порядок auth-токена ядро↔журнал (живой резолвер во всех потребителях), скан меты знаний по одной странице событий (с пустыми пропозициями в вердиктах конфликтов как следствие), вежливый поллинг HITL в драйвере. Повтор сотни на исправленном стеке — таблица выше. Нагрузочное тестирование как процесс находки → фиксы → доказательство починки.
Известные точки роста (за пределами сотни на 6 ядрах):
GET /v1/observations/knowledgeсknowledge_id(фолбэк карточек сравнения supervisor) перепроектирует всю историю знаний проекта — ~600 ms на вызов; для больших инсталляций нужен точечный эндпоинт.POST /v1/observations/hints(~750 ms, семантический тир на CPU): старт-бёрст сотни ранов — сотня вызовов за минуты; инференс эмбеддингов масштабируется воркерамиKERNEL_EMBEDDING_WORKERS.- Рост параллелизма за ~150–200 ранов на такой VM упрётся в CPU — горизонтальное масштабирование воркеров ядра и journal.
Две из трёх точек роста закрыты волной оптимизаций горячего пути — см. §6.1.
#6.1. Волна оптимизаций горячего пути (A/B, light-профиль, 2026-10-27)
Аудит производительности (внутренние материалы) переработал горячий
путь: события хода — через LocalActivity вместо activity round-trip на
каждое событие, батч-доставка outbox (до 50 событий одним POST), контейнер
песочницы на ран (exec вместо run+rm на команду), кэш проекции знаний
для hints/explore, LISTEN/NOTIFY-будильник SSE-стримов, персистентная
MCP-сессия, шардированный TokenBus, параллельные title+hints на старте.
A/B на одном хосте и одной БД (macOS, Docker Desktop; kernel/journal/Temporal в контейнерах): light-профиль, 25 параллельных ранов, ramp 30 с, рабочая песочница (после фикса контракта мока). Абсолютные числа light-профиля не сравнимы с heavy-лестницей выше — сравнивается только разница сторон.
| Метрика | main | ветка оптимизаций | Δ |
|---|---|---|---|
| Длительность рана p50 | 15 424 ms | 4 087 ms | −74% (3.8×) |
| Длительность рана p95 | 18 817 ms | 5 702 ms | −70% |
| Старт-API p95 | 11 ms | 11 ms | = |
run_command p50 (2 команды на ран) |
497–640 ms | 98 ms | 5.2× |
| Пик CPU postgres | 23–130% | 13–34% | ≈ −60% |
| Пик CPU kernel / journal | 67% / 31–58% | 46% / 24–32% | −32% / −30% |
Ключевое: на ветке медиана рана — константа при росте 5 → 25 параллельных
(4 095 → 4 087 ms), тогда как main деградирует (5.3 s → 9.3–10.2 s,
p95 до 16 s) — налог Temporal round-trip'ов на каждое журнальное событие
платил именно под конкурентностью. Kernel на ветке занят чуть меньше,
несмотря на перенос исполнения LocalActivity в свой процесс: исчезают
ожидания server round-trip'ов. Атрибуция по слоям — во внутренних
материалах аудита.
Перезамер heavy-профиля на этой ветке (с рабочей песочницей) — следующий шаг методологии; ожидание: константа длительности сохранится, а компонента песочницы впервые будет измерена честно.
#Сколько это «пользователей»: математика ёмкости
Ран ≈ одна агентская сессия (диалог с задачей). Пользователь активно работает с агентом не непрерывно: за час работы у него набегает 3–6 сессий, но одновременно выполняется обычно 0–2. Для ёмкости считаем одновременные раны, а не учётки:
одновременные раны ≈ активные пользователи × среднее число
параллельных сессий на пользователя (0.3–1.5)
Для heavy-профиля (самый жёсткий: длинные стримы + песочницы + HITL) измеренный бюджет — ~20 одновременных ранов на ядро (пик ~5.3 ядра на сотне). Лёгкие сессии (короткие ответы, без песочниц) легче в 5–20 раз.
| Железо (только под Temporality) | Тяжёлые сессии одновременно | Оценка пользователей (типичный день)* |
|---|---|---|
| 4 ядра / 16 ГБ | ~70 | ~200–400 |
| 6 ядер / 32 ГБ (тестовый стенд) | ~100+ | ~300–600 |
| 16 ядер / 64 ГБ | ~250–300 | ~800–1 500 |
| 32 ядра / 128 ГБ | ~500–600 | ~1 500–3 000 |
*Пользователи ≠ одновременные сессии: при 3–6 сессиях на час и средней сессии в 2–5 минут коэффициент одновременности ≈ 0.05–0.15 на «дневного» пользователя. Числа — для планирования, не SLA: реальный профиль нагрузки стоит измерить по §3.1 на своём железе (моки дают детерминированную базу, далее — калибровка на реальном провайдере).
RAM почти не ограничивает (сотня ранов — сотни мегабайт на сервис); первый ограничитель — CPU журнала и Postgres, второй — параллельные песочницы (8 на стенде — это лимит docker, поднимается конфигурацией). Провайдер модели — внешний: его рейт-лимиты в ёмкость не входят, для честного НТ используется мок.
#Как повторить
§3.1 — пошаговая инструкция; коротко:
git pull && docker compose -f docker-compose.yml -f docker-compose.dev.yml \
--profile loadtest up -d
cd /srv/temporality # или где репозиторий
sudo env TOKEN=<bearer> scripts/loadrun.sh 100 180 run100.md --heavy --hitl
Скрипт сам: соберёт драйвер, применит профиль к моку (compose override, литералами, с assert'ами env и boot-строки), проверит токен и песочницу, запустит сэмплер ресурсов и напишет markdown-отчёт с метриками мока.