🧩 Боты на платформах: параллельные партии, рантаймы и лимиты
О чём это
Один вопрос, у которого на разных платформах разные ответы: что происходит, когда у бота одновременно начинаются две партии, а он думает над ходом долго? Ответ определяется не алгоритмом, а тем, сколько независимых процессов платформа поднимает под N параллельных запросов. Отсюда же вытекают деньги: где бесплатный тариф покрывает бота целиком, где он выгорает за день, и где вообще невозможно получить счёт. Проверено 30.07.2026 по коду play-api, конфигам живых сервисов и текущим прайс-листам.
Первое, что нужно уложить: ход инициирует сервер, а не бот. Из Webhooks.scala в play-api — когда наступает очередь зарегистрированного бота, сервер сам делает синхронный POST на его вебхук с состоянием партии и ждёт в ответ {"moves":["e2e4",...]}.
sequenceDiagram
participant R as GameRoom (play-api)
participant W as Webhooks (dispatcher)
participant B as Бот (платформа)
R->>W: очередь бота, есть бросок костей
W->>W: timeout = min(конфиг, остаток часов)
W->>B: POST /api/webhook {state, seat}
Note over B: думает, но обязан<br/>вернуть ход до дедлайна
B-->>W: 200 {"moves":[...]}
W->>R: submitTurn (как обычный игрок)
Три свойства этого контракта задают всё остальное:
Одна попытка, без ретраев. В докстринге диспетчера это названо прямо: «reliability is the clock». Таймаут, не-200 или мусор в ответе — сервер просто ничего не делает, и партию решают часы, точно так же как для polling-бота, который перестал опрашивать. Нет очереди, нет dead-letter, нет состояния диспетчера.
Бот — не второй писатель. Раннер вебхука подписан на комнату как обычный источник команд и кормит submitTurn. Single-writer доктрина GameRoom не нарушается.
Доставка структурно ограничена. Бот получает максимум один POST на один свой ход в партии, где он сидит. Количество партий ограничено сверху LADDER_MAX_CONCURRENT_PAIRS (по умолчанию 4) и потоком челленджей — накачать очередь запросов извне невозможно.
Три потолка времени
Долгое размышление ограничено тремя независимыми потолками, и срабатывает самый низкий:
Обрезает шортлист, но всегда возвращает ход — anytime-контракт TimeBudgetedSearch
play-api (Webhooks)
min(WEBHOOK_TIMEOUT_SECONDS, остаток часов бота), минимум 1 мс
Бросает попытку, ничего не применяет — дальше решают часы
Платформа
Cloud Run 60 с · Workers 10 мс CPU · Lambda до 15 мин
Рвёт запрос принудительно
Бот не «успевает посмотреть всех кандидатов» — он умеет вовремя остановиться
Это принципиально. HunterSearch — TimeBudgetedSearch, дедлайн доходит до него, и дорогая фаза (рескор шортлиста через KingCaptureProbability) режется по факту истечения времени. Поэтому в замерах ноль флагов не означает «всё просчитано» — означает «отдал лучшее из просмотренного до дедлайна». Гарантия здесь — легальный ход всегда, а не полный перебор.
2. Главная переменная: сколько процессов на N партий
Вот развилка, из которой растут все различия между платформами.
В Strategy.chooseMoves стоит synchronized — и это не перестраховка, комментарий в коде объясняет причину: «a webhook server can be handed two turns at once and the search reuses nothing thread-safe by contract». То есть внутри одного процесса все ходы по всем партиям считаются строго по одному.
Дальше всё зависит от того, что платформа делает со вторым одновременным запросом:
Режим A: один процесс на все партии
Кто так работает: homelab (rpi3/rpi4/dexus), Oracle A1 VM, любой обычный контейнер с concurrency > 1.
Второй запрос приходит в тот же процесс и встаёт в очередь за локом. Пока бот думает над ходом в первой партии, запросы по второй и третьей ждут. Если худший ход занимает 24 секунды (замерено для hunter-all с candidateLimit=all на dexus), то соседние партии получают ответ на 24 секунды позже — и эти секунды списываются с их часов.
Риск реализуется тем сильнее, чем толще хвост задержки. Для limit=12 (max ~230 мс) очередь незаметна; для limit=all — заметна.
Режим B: процесс на партию
Кто так работает: Cloud Run с containerConcurrency: 1, Azure Functions, AWS Lambda, Cloudflare Workers.
Второй одновременный запрос не встаёт в очередь — платформа поднимает второй инстанс. Партии считаются в разных процессах, полностью параллельно, и synchronized никогда не встречает конкуренции. Потолок параллелизма — настройка масштабирования (maxScale: 10 у нашего Cloud Run сервиса).
Плата — двойная:
Холодный старт. Каждый новый инстанс поднимается с нуля. Для JVM это 5–20 с — на синхронном контракте с одной попыткой это прямой проигрыш по времени. Для native-образа или JS — десятки миллисекунд.
Тарификация. Секунды считаются с каждого инстанса отдельно. Две параллельные партии = двойное списание за это время.
Wake-проба покрывает каталог, но не лестницу
POST /lobby/bots/{team}/{name}/wake сайт зовёт по клику в каталоге — до старта часов, чтобы принудительно разбудить scale-to-zero бота. Планировщик лестницы её не вызывает. Поэтому лестничная партия после простоя начинается с холодного старта, и он съедается из банка времени. Два подряд флага по времени → сервер автоматически парки́рует бота (#150).
3. Три рантайма одного движка
Неочевидное следствие: платформа диктует рантайм, а рантайм — какие алгоритмы вообще влезают. У нас в проекте живут три разных сборки одного и того же dicechess-engine-scala:
Из README dicechess-bot-azure — формулировка, которую стоит запомнить целиком: контракт синхронный, с жёстким бюджетом и одной попыткой доставки, поэтому холодный старт JVM в 5–20 с — «real competitive liability». Native-образ стартует за десятки миллисекунд, а движок — чистая битбордовая арифметика без рефлексии, поэтому образ собирается с --no-fallback без конфигов.
Из README dicechess-bot-cloudflare — измеренные на самом движке цифры под лимит Workers:
Алгоритм
p50
худшее
Влезает в 10 мс free?
aggressive-book
~0.4 мс
~3.4 мс
да, с запасом
monte-carlo
сотни мс
—
нет
hunter (limit=12)
~32 мс
~230 мс
нет
Почему hunter не поедет на Workers free
KingCaptureProbability — это DFS по 216 исходам броска, и оценщик хочет два вызова на позицию. Даже на плато (limit=12) это ~32 мс медианы, то есть втрое над бесплатным потолком Workers. На платном плане (30 с по умолчанию) влезет легко. Это ровно тот случай, когда платформа отсекает алгоритм, а не наоборот.
4. Платформы по одной
🏠 Homelab
Хост
CPU
RAM
Роль
rpi3 (192.168.20.99)
4× Cortex-A53
905 МБ
lab/hunter-k6
rpi4 (192.168.10.9)
4× Cortex-A72
3.7 ГБ
lab/hunter-k48 + homelab-сервисы
dexus (192.168.10.4)
4× i5-2500
3.2 ГБ
lab/hunter-all
aurora (192.168.10.3)
—
—
прод, ботов не селим
Параллелизм: режим A — один контейнер, лок сериализует. Реально кусает только hunter-all.
Публичный доступ: через named tunnel на aurora — Public Hostname в Zero Trust указывает на http://<LAN-IP>:<порт>. L3-роутинг между подсетями .10.x и .20.x работает (в отличие от WoL-броадкаста, который через границу не проходит).
Цена: электричество. Малинки — единицы ватт, dexus (i5-2500, TDP 95 Вт) — десятки, и он теперь не засыпает: крон авто-выключения на rpi4 отключён под always-on бота.
Лимит памяти на rpi3 — реальная граница, а не теория
При 905 МБ физической памяти два контейнера с лимитами 512 + 384 МБ = 896 МБ занимали почти всё, и хост ушёл в swap на microSD (535 МБ в свапе). Свап на SD — медленный I/O прямо на пути ответа вебхука. Вылечено переносом hunter-k48 на rpi4. Сумма лимитов контейнеров должна оставлять хосту запас, иначе платформа-без-биллинга наказывает задержкой.
☁️ Google Cloud Run
Живой сервис hunter (проект dice-chess-lab, регион europe-west3), фактический конфиг:
containerConcurrency: 1 # → режим B, инстанс на партию
cpu: 2, memory: 1Gi
maxScale: 10 # потолок параллельных партий
timeoutSeconds: 60
startup-cpu-boost: true # смягчает холодный старт JVM
minScale: не задан # → scale-to-zero
Бесплатно в месяц: 180 000 vCPU-секунд, 360 000 GiB-секунд, 2 млн запросов.
Что это значит в единицах бота — считаем через --cpu 2:
2vCPU180000vCPU-s=90000s=25h
То есть 25 часов размышления в месяц — при --cpu 1 было бы 50.
Запросы никогда не станут узким местом: партия — это ~13 POST’ов на сторону, то есть 2 млн запросов ≈ 150 тыс. партий. Всегда бьёт CPU, и всегда первым.
Бесплатный тариф — на проект, а не на сервис. И его выедает сосед
Вот почему lab/hunter стоит €3/мес, хотя по расчёту первые ~25 часов должны быть бесплатны: gcp/scala-monte-carlo при €113/мес сжигает ≈4.7 млн vCPU-секунд, то есть 26× месячной бесплатной квоты — примерно за первые сутки. Дальше все сервисы проекта платят с первой секунды. Счёт hunter’а — не признак дорогого hunter’а, а признак того, что квоту уже съели.
Как удержаться в лимите:
--cpu 1 вместо 2 — удваивает бесплатные часы (для 1-ply оценщика потери силы почти нет);
--max-instances — жёсткий потолок параллелизма, значит и скорости выгорания;
--concurrency > 1 — партии делят инстанс (но тогда возвращается лок из режима A);
убрать тяжёлого соседа с лестницы (POST /bot/ladder/leave) — самый крупный рычаг;
LADDER_MAX_CONCURRENT_PAIRS на стороне play-api — глобальный дроссель для всех ботов сразу.
Чего НЕ делает бюджет: billing budget в GCP только присылает письмо. Он не останавливает трату. Жёсткий стоп требует связки budget → Pub/Sub → Cloud Function, отключающей биллинг проекта. Подробности и текущие цифры — отдельная заметка.
Открытый вопрос: применим ли free tier в europe-west3
Источники противоречат друг другу: часть утверждает, что бесплатная квота Cloud Run действует только в us-central1/us-east1/us-west1, часть — что Франкфурт тоже входит. Эмпирически наш сервис в europe-west3тарифицируется, но это объясняется и выеданием квоты соседом (см. выше), поэтому чистого эксперимента нет. Проверять по своему счёту, а не по статьям.
🟠 Oracle Cloud (Ampere A1)
Always Free на всю тенантность суммарно: 4 OCPU + 24 ГБ RAM, 200 ГБ блочного хранилища, 10 ТБ исходящего трафика/мес, VCN и сеть целиком, 2 публичных IPv4.
Параллелизм: это обычная VM → режим A. Один процесс, лок сериализует — ровно как homelab, но в облаке и без счёта за электричество. oracle/v1–v3 играют так за €0.
Лимит общий, а не на инстанс
«4 OCPU бесплатно» — это потолок на сумму по всем A1-инстансам. Две машины по 4 ядра дают 8, то есть 4 сверх лимита. Именно так у нас появился счёт €4/день: до 23.07 всё укладывалось в 4 OCPU, а под хакатон подняли 16-ядерную машину.
Как удержаться: держать сумму OCPU ≤ 4. Контроль здесь самый честный из всех облаков — превысить можно только сознательно провизионив больше ресурсов, случайного выгорания по трафику не бывает. Детали — Oracle Cloud: что бесплатно.
🔵 Azure Functions
Наш azure/scala-aggressive-book — Function App на custom handler с GraalVM native-образом (--runtime custom, enableForwardingHttpRequest).
Бесплатный грант (Consumption plan), на подписку в месяц: 1 млн исполнений + 400 000 GB-секунд.
Для native-образа это фактически неограниченно под нашу нагрузку: ход считается миллисекундами, при минимальной тарифной единице 128 МБ × 100 мс это ~0.013 GB-с на исполнение, то есть грант по GB-с покрывает десятки миллионов ходов. Узкое место — 1 млн исполнений, а это ≈77 тыс. партий в месяц. Лестница столько не сыграет.
Единственный настоящий жёсткий стоп из всех облаков
У Azure Functions есть dailyMemoryTimeQuota — суточная квота в GB-секундах, при превышении которой Function App переводится в Disabled до 00:00 UTC. Это не письмо, а реальная остановка.
az functionapp update --resource-group <rg> --name <app> \ --set dailyMemoryTimeQuota=13000
Нюансы: увеличение квоты применяется только со следующих суток, и после снятия нужен рестарт приложения. Для бота это идеальный предохранитель — худшее последствие в том, что бот замолчит и его запаркует лестница, а не в том, что придёт счёт.
🟡 AWS
Lambda — always free, навсегда: 1 млн запросов + 400 000 GB-секунд в месяц. Не истекает, в отличие от остального.
Профиль тот же, что у Azure Functions: под native-бинарь (custom runtime provided.al2023) грант практически не расходуется, узкое место — 1 млн вызовов, чего лестнице хватит с многократным запасом. Режим B, параллелизм ограничивается reserved concurrency.
Контейнерные варианты:
App Runner — с 30.04.2026 закрыт для новых клиентов, как площадку для нового бота не рассматриваем;
EC2 t4g.small — бесплатный трайл до 750 ч/мес продлён до 31.12.2026; это ARM-машина, то есть режим A, аналог Oracle A1 с дедлайном;
Fargate — бесплатного тарифа нет.
Модель free tier у AWS изменилась в июле 2025
Вместо прежних «12 месяцев бесплатно» теперь $200 кредитов + 6-месячный free plan. Always-free сервисы (в том числе Lambda) сохранились, но общая рамка стала кредитной и конечной — планировать долгоживущего бота стоит на always-free строчках, а не на кредитах.
Как удержаться: reserved concurrency на Lambda (потолок параллелизма), AWS Budgets с budget actions — в отличие от GCP, они умеют не только уведомлять, но и применять политику (например, отобрать права на запуск). Жёсткого «стоп-биллинга» на аккаунт всё равно нет.
🟣 Cloudflare Workers
Наши cloudflare/scala-aggressive-book и cloudflare/greedy — Workers на TypeScript-обёртке (~40 строк) вокруг Scala.js-сборки движка. WebCrypto — глобал в Workers, поэтому nodejs_compat не нужен.
Бесплатный план: 100 000 запросов/сутки, 10 мс CPU на запрос.
Параллелизм: режим B, но без холодного старта вообще — изолят поднимается мгновенно. Для синхронного контракта с одной попыткой это лучший профиль из всех платформ.
Единственная платформа, где на бесплатном плане невозможно получить счёт
Free plan у Workers — жёсткий по конструкции: превышение суток отдаёт ошибку, превышение 10 мс CPU убивает этот запрос, а не аккаунт. И заметьте, что означает убитый запрос в нашем контракте: один ход проигран по часам, партия продолжается — это не падение бота. Контроль расхода тут не нужен, потому что расхода нет.
Ограничение — обратная сторона: 10 мс отсекает всё, кроме дешёвых оценщиков (см. таблицу в разделе 3).
5. Сводка: бесплатные лимиты
Платформа
Бесплатно в месяц
Режим
Что бьёт первым
Жёсткий стоп?
Cloudflare Workers
100 тыс. запросов/сутки, 10 мс CPU
B, без cold start
CPU на запрос
✅ по конструкции
Azure Functions
1 млн исполнений + 400 тыс. GB-с
B, cold start ~мс
исполнения (≈77 тыс. партий)
✅ dailyMemoryTimeQuota
AWS Lambda
1 млн запросов + 400 тыс. GB-с
B, cold start ~мс
запросы
⚠️ только reserved concurrency + budget actions
Oracle A1
4 OCPU + 24 ГБ (на тенантность)
A, always-on
сумма OCPU
✅ провизионинг сознательный
GCP Cloud Run
180 тыс. vCPU-с, 360 тыс. GiB-с, 2 млн запросов
B, cold start 5–20 с (JVM)
vCPU-секунды
❌ бюджет только уведомляет
Homelab
— (электричество)
A
RAM, затем swap
—
6. Практические выводы
Что где держать. Тяжёлые CPU-голодные алгоритмы (KCP, monte-carlo) не должны жить на посекундной тарификации — их место на Oracle A1 Always Free или в homelab, где секунда размышления ничего не стоит. Cloud Run хорош ровно для того, для чего мы его используем сейчас: честное сравнение алгоритмов на одинаковом процессоре, за понятные деньги и на ограниченное время.
Дешёвые оценщики — на edge.aggressive-book на Workers free — идеальный профиль: без холодного старта, без счёта, с запасом по CPU в 3× на медиане.
Native-образ снимает главный минус serverless. Azure Functions и Lambda с GraalVM-бинарём дают режим B (параллельные партии) без платы холодным стартом и практически без расхода бесплатного гранта. Для 1-ply оценщика это объективно лучшая комбинация цены и профиля задержки.
Один процесс на все партии — это архитектурный выбор, а не деталь.synchronized в Strategy безопасен и дёшев ровно до тех пор, пока хвост задержки короткий. Как только появляется вариант с толстым хвостом (limit=all, 24 с в максимуме), режим A превращается в очередь, которая тратит часы соседних партий. Либо режим B, либо короткий хвост — совмещать «один процесс» и «долгий поиск» нельзя.
Считать в единицах бота, а не в единицах прайс-листа. «180 000 vCPU-секунд» ничего не говорит; «25 часов размышления в месяц при --cpu 2» — говорит всё. Полезно один раз перевести бесплатный лимит каждой платформы в часы/партии и держать эту таблицу под рукой.
И главное про контроль: только у Cloudflare (по конструкции), Azure (dailyMemoryTimeQuota) и Oracle (провизионинг) превысить бесплатный лимит трудно или невозможно. У GCP бюджет — это письмо, а не тормоз; у AWS — политика, а не тормоз. Если задача звучит как «бот не должен стоить денег ни при каких обстоятельствах», выбор площадки этим и определяется.
Внутренние источники фактов: Webhooks.scala и AGENTS.md в dicechess-play-api, Strategy.scala в dicechess-hunter, README dicechess-bot-azure и dicechess-bot-cloudflare, gcloud run services describe hunter.