temporality
Изменить страницу↗
Инженерные документы

Отказоустойчивость и масштабируемость

Статус: принято. Связанные документы: failure-model.md (семантика сбоев и доставки событий), failure-reconciliation.md (реконсиляция неопределённых операций), supervisor-model.md (триаж и человеческий фактор).

Этот документ фиксирует, как ядро ведёт себя при сбоях инфраструктуры, какими ресурсами ограничен воркфлоу рана и почему приняты ключевые архитектурные решения вокруг Temporal — включая те, от которых осознанно отказались.

#1. Temporal как слой долговечности: replay коротко

Воркфлоу рана — event-sourced: каждый шаг (вызов модели, инструмента, запись события журнала) — отдельная активность, результат которой неизменяемо записан в историю воркфлоу. При падении воркера, деплое или перезапуске Temporal поднимает код воркфлоу заново и переигрывает историю: уже выполненные активности не запускаются повторно (ни LLM-токены, ни команды песочницы), их результаты подставляются из истории. Ран переживает рестарты воркера и деплои без потери прогресса — это фундамент отказоустойчивости, а не её нарушение.

Следствия, зафиксированные в коде:

#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. Наблюдаемость границ

#3.1. Нагрузочное тестирование: пошаговая инструкция

Инструменты в репозитории; провайдеры не задействованы, всё локально и воспроизводимо:

Перед кампанией: проверяйте контракт мока. Скрипт ассертит профиль токенов мока, но не формат его 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)).

#Подготовка стенда (однократно)

  1. Требования: Linux + Docker, Go (для сборки драйвера), свободный диск 50+ ГБ. Hyper-V/виртуалка: фиксированная память (не dynamic), swap 8 ГБ.
  2. Исходники и env:
git clone https://github.com/RuslanGabbasov/temporality.git && cd temporality
cp .env.example .env
  1. В .env на время кампании:
TEMPORALITY_MODEL_BASE_URL=http://mockllm:8081/v1
TEMPORALITY_MODEL_ID=loadgen-model
# KERNEL_AUTH_TOKENS — закомментирован (иначе онбординг-режим недоступен)
# KERNEL_SANDBOX_IMAGE — закомментирован (иначе шаг сборки песочницы 409)
  1. Поднять стек с моком:
docker compose -f docker-compose.yml -f docker-compose.dev.yml --profile loadtest up -d
  1. Снапшот виртуалки — страховка и точка сравнения.

#Кампания

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. Отказоустойчивость деплоя и эксплуатации

#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

Ключевые выводы:

История итераций: первый прогон сотни (до фиксов) дал 0/100 терминальных ранов и перегруз журнала. Разбор по слоям нашёл и починил: неприменённый профиль мока (compose override + assert'ы), старт-порядок auth-токена ядро↔журнал (живой резолвер во всех потребителях), скан меты знаний по одной странице событий (с пустыми пропозициями в вердиктах конфликтов как следствие), вежливый поллинг HITL в драйвере. Повтор сотни на исправленном стеке — таблица выше. Нагрузочное тестирование как процесс находки → фиксы → доказательство починки.

Известные точки роста (за пределами сотни на 6 ядрах):

Две из трёх точек роста закрыты волной оптимизаций горячего пути — см. §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-отчёт с метриками мока.