ADR-012: Rust Webhook Sync Architecture
Архитектурные решения по синхронизации данных между Rust game server (Oxide plugin) и GoLoot backend.
1. Summary
Goal: Определить оптимальный паттерн доставки игровых событий (gather, craft, kill, fish и т.д.) с Rust сервера на GoLoot backend для трекинга квестов.
Constraints: Oxide/uMod (платформа плагинов) ограничивает внешнюю коммуникацию только HTTP через webrequest.Enqueue. WebSocket, message queues, и прямые TCP-соединения недоступны.
Decision: Push-based HTTP с буферизацией + mega-batch periodic sync + V2 protocol (plugin-side matching).
2. Почему только HTTP
Ограничения Oxide/uMod
Oxide — модфреймворк для Rust, работающий поверх Unity/.NET. Плагины написаны на C# и имеют ограниченный доступ к системным API:
| Возможность | Доступность | Детали |
|---|---|---|
| HTTP requests | webrequest.Enqueue() | Async, callback-based, background thread pool |
| File I/O | Interface.Oxide.DataFileSystem | Чтение/запись JSON файлов |
| SQLite | Oxide.Core.SQLite | Локальная база данных |
| MySQL | Oxide.Core.MySql | Прямое подключение к MySQL |
| WebSocket | Недоступен | Нет API в Oxide |
| TCP/UDP sockets | Недоступен | Unity sandbox |
| Redis/RabbitMQ/Kafka | Недоступен | Нет клиентских библиотек |
| gRPC | Недоступен | Требует HTTP/2, не поддерживается |
webrequest.Enqueue — единственный способ связи плагина с внешним миром по сети. Все архитектурные решения строятся вокруг этого ограничения.
Что это значит для архитектуры
Плагин может только отправлять HTTP запросы. Он не может:
- Принимать входящие соединения (не HTTP-сервер)
- Держать persistent connection (нет WebSocket)
- Использовать message broker (нет клиентских библиотек)
- Использовать pub/sub (нет Redis client)
Единственный доступный паттерн: push-based HTTP.
3. Рассмотренные альтернативы
Альтернатива A: WebSocket (persistent connection)
Plugin ←→ Backend: постоянное соединение, события в реальном времени
Почему идеален: Нет HTTP overhead, bidirectional, real-time. Почему невозможен: Oxide не предоставляет WebSocket API. Подключение сторонних .NET WebSocket библиотек в Oxide ненадёжно (sandbox ограничения, несовместимость с Unity runtime).
Carbon (альтернативный mod loader) предоставляет Bridge — WebSocket-коммуникацию на базе Fleck. Однако Bridge спроектирован для C#-to-C# межсерверной связи между Rust-серверами, а не для связи с внешними backend-системами. Протокол бинарный (BridgeRead/BridgeWrite), привязан к Carbon SDK, не имеет документированного wire format и JS/Node.js клиентов. Подключение Node.js backend потребовало бы реверс-инжиниринга проприетарного протокола — нецелесообразно.
Вывод: ни Oxide, ни Carbon не предоставляют WebSocket API для связи с внешним backend. HTTP (webrequest.Enqueue) — единственный доступный метод на обеих платформах.
Альтернатива B: Message Queue (Redis/RabbitMQ/Kafka)
Plugin → Queue → Backend: асинхронная очередь, backend потребляет в своём темпе
Почему идеален: Decoupled, масштабируемый, natural retry. Почему невозможен: Oxide не имеет клиентских библиотек для message brokers. MySQL доступен, но использование его как очереди — антипаттерн.
Альтернатива C: Pull-based (backend тянет данные)
Backend → Plugin: backend периодически запрашивает данные у плагина
Почему невозможен: Плагин не HTTP-сервер. К нему нельзя отправить входящий запрос.
Альтернатива D: Push-based HTTP (выбранный вариант)
Plugin → Backend: плагин копит события в буферах, периодически отправляет HTTP запросами
Почему выбран: Единственный технически возможный паттерн в рамках Oxide.
4. Варианты реализации push-based HTTP
При выбранном паттерне (push-based HTTP) остаётся вопрос: как часто и чем синхронизировать?
Вариант A: Timer-based (каждые 60 секунд)
Каждые 60s → flush всех буферов → отдельный webhook на каждый тип события
- TMA показывает свежий прогресс (задержка ≤60s)
- Потеря данных при крэше сервера ≤60s
- Недостаток: Много HTTP запросов (отдельный webhook на каждый тип × каждого игрока)
Вариант B: Event-driven (только по причине)
Quest completed → SEND
Player disconnected → SEND
Heartbeat (5 мин) → SEND
Расчёт для 500 активных игроков за 5 минут:
| Событие | Количество |
|---|---|
| Quest completions | ~100 |
| Disconnects | ~30 |
| Heartbeats (раз в 5 мин) | 500 |
| Итого | ~630 |
- 4× меньше запросов чем Вариант A
- Критический недостаток: Прогресс в TMA обновляется с задержкой до 5 минут
- Критический недостаток: При крэше сервера теряется до 5 минут прогресса
- Усложнённая event-driven логика
Вариант B экономит запросы, которые не являются проблемой. 350 req/min для Fastify — не нагрузка. В обмен мы теряем UX (5 мин задержка прогресса) и увеличиваем окно потери данных при крэше.
Вариант C: Гибрид — timer-based + mega-batch (выбранный → эволюция в V3)
V2 (устарел):
Quest completed → SEND immediate (один тип, InFlight guard)
Каждые 60 секунд → SEND mega-batch (все буферы ОДНИМ запросом на игрока)
Player disconnected → SEND flush (все буферы)
V3 (текущий — Server-Side Batching):
Каждые 60 секунд → SEND один batch (ВСЕ игроки в ОДНОМ запросе)
Quest completed → Локальное уведомление, подтверждение в batch response
Player disconnected → DisconnectedBuffer → следующий batch
- Прогресс в TMA свежий (≤60s)
- Потеря данных при крэше ≤60s
- Completion квеста — мгновенный (chat), подтверждение ≤60s (backend)
- Один HTTP запрос на ВСЕ players вместо N запросов
5. Выбранная архитектура: Вариант C (V3 — Server-Side Batching)
Как это работает (V2 Protocol + Server-Side Batching)
Ключевое изменение: Immediate sends удалены. Все progress updates идут через единый batch каждые 60s. Квест-completion определяется локально на плагине (chat notification), а подтверждается через batch response от backend. Задержка подтверждения ≤60s — приемлемо для TMA.
Путь отправки (Server-Side Batching)
| Путь | Когда | Что отправляет | Задержка |
|---|---|---|---|
| Batch | Каждые 60s | QUEST_PROGRESS_BATCH: все игроки одним запросом | ≤60s |
| Per-player | Connect / Disconnect | PLAYER_CONNECTED / PLAYER_DISCONNECTED (lifecycle) | ~0ms |
При disconnect буферы перемещаются в _disconnectedBuffers (expiry 5 min). Данные включаются в следующий batch. При reconnect до batch — буферы мержатся обратно в сессию.
Batch Guard
Заменяет per-player InFlight Guard. Глобальный флаг _isBatchSending предотвращает отправку следующего batch до получения ответа на текущий (timeout 30s).
Зачем нужен Batch Guard
Проблема: Если batch processing на backend занимает >60s, следующий timer tick попытается отправить новый batch до завершения предыдущего.
Решение: _isBatchSending флаг + _batchSentAt timestamp. Если batch в полёте >30s — timeout reset, разрешает новую отправку. Буферы очищаются до отправки, поэтому повторная обработка тех же данных невозможна.
Подробнее: ADR в rust-integration.md
6. Расчёт нагрузки
Формула (V3 — Server-Side Batching)
HTTP requests/мин = 1 (один batch каждые 60s) + Connects/мин + Disconnects/мин
Таблица нагрузки (server-side batching, 60s interval)
| Игроков | Активных | HTTP req/мин | Payload size (avg) | Backend processing | Rate limit headroom |
|---|---|---|---|---|---|
| 50 | 35 | ~3 | ~5 KB | Trivial | ~130× |
| 100 | 70 | ~5 | ~10 KB | Trivial | ~80× |
| 200 | 150 | ~7 | ~20 KB | OK (~3s) | ~57× |
| 500 | 350 | ~12 | ~50 KB | OK (~7s, 7 chunks) | ~33× |
1 batch + lifecycle events (CONNECTED/DISCONNECTED). При 500 игроках ~5 connects + ~5 disconnects в минуту. Основная нагрузка — один batch.
Сравнение: V1 → V2 → V3
500 активных игроков:
| Метрика | V1 (8 таймеров) | V2 (per-player mega-batch) | V3 (server-side batch) | V1→V3 |
|---|---|---|---|---|
| Таймеров | 8 | 1 | 1 | 8× |
| HTTP req/мин | ~3000+ | ~350 | ~12 | ~250× |
| Burst за тик | ~3000 | ~350 | 1 | ~3000× |
| Rate limit headroom | ~1.3× | ~2× | ~33× | ~25× |
| Backend processing | Перегружен | ~6s | ~7s (chunked) | OK |
Backend capacity
Fastify + Prisma (25 connections). Batch processing: 50 players per chunk, Promise.allSettled. Для 350 активных игроков = 7 chunks × ~1s = ~7s processing time. Запас по таймауту: 53s из 60s.
7. Буферизация и data safety
Линии защиты данных (V3 — Server-Side Batching)
Событие в игре → ProgressBuffer (in-memory)
│
├─ 1. Local completion detection (chat) ~0ms
├─ 2. Server-side batch (60s timer) ≤60s
├─ 3. Batch response confirmation ≤60s
├─ 4. DLQ batch retry (30s, TTL 10min) при failure
├─ 5. DisconnectedBuffer (5min expiry) при disconnect
├─ 6. Reconnect merge при reconnect
└─ 7. Crash recovery (PROCESSING status) при backend crash
Когда данные могут быть потеряны
| Сценарий | Потеря | Mitigation |
|---|---|---|
| Backend недоступен ≤30 мин | Нет | DLQ хранит и retries |
| Backend недоступен >30 мин | До 30 мин данных | DLQ TTL (configurable) |
| Server crash (kill -9) | До 60s данных | PendingSaveInterval (periodic disk save) |
| Graceful disconnect | Нет | FlushAllBuffers |
При любом нормальном disconnect (выход, timeout, kick) FlushAllBuffers принудительно отправляет ВСЕ буферы. Данные теряются только при hard crash сервера.
8. Server-Side Batching: реализация (V3)
Эволюция: V1 (per-type handlers) → V2 (plugin-side matching, per-player webhooks) → V3 (server-side batching, один запрос для ВСЕХ игроков). Backend принимает QUEST_PROGRESS_BATCH с данными всех игроков и вызывает handleQuestProgress() per-player через chunk processing.
Plugin: SendBatchQuestProgress (1 таймер)
// OnServerInitialized — один таймер
timer.Every(QuestProgressInterval, SendBatchQuestProgress);
// + отдельный таймер для AFK detection:
timer.Every(AfkCheckInterval, CheckAfkAndAccumulateTime);
SendBatchQuestProgress():
- Проверяет
_isBatchSending(global guard, timeout 30s) - Собирает данные из
_activeSessions(ProgressBuffer + AbsoluteBuffer + TIME) - Собирает данные из
_disconnectedBuffers(expire >5 min) - Skip если пустой batch (players.Count == 0)
- Snapshot time data для accounting on success
- Очищает все буферы ДО отправки (prevents duplicates on retry)
- Отправляет
POST /rust/webhook/batchс payload всех игроков - On success: обновляет time accounting, парсит response (
ProcessBatchResponse) - On failure: добавляет BatchPayload в DLQ
Backend: WebhookBatchService
processBatch() в webhook-batch.service.ts:
- Дедупликация: INSERT batch log (status=PROCESSING, dedup key=
batch:{webhookId}). P2002 + PROCESSED → skip duplicate. P2002 + PROCESSING → re-process (crash recovery) - Resolve users: один
findMany WHERE steamId IN [all]→Map<steamId, user> - Per-player logs:
createManyс eventType=QUEST_PROGRESS, parentBatchId=batchLogId - Chunk processing (50):
Promise.allSettled→handleQuestProgress()per-player. Update per-player log status - Finalize: batch log → PROCESSED (или FAILED если все failed) + stats
Per-player handler (переиспользован без изменений)
handleQuestProgress() в webhook-handlers.service.ts:
- Snapshot: получает
getActiveRustQuests()— метаданные + targetProgress - Строит
metaMap: userQuestId →{eventType, targetProgress} - Параллельно (
Promise.allSettled) применяетapplyProgressIncrement()для каждого update - Skip stale updates (userQuestId не в активном snapshot)
- TIME piggyback: если есть
minutesPlayed— обновляет TIME квесты - Возвращает
completedQuests+ обновлённыеactiveQuestsс match criteria
N+3 queries (parallel): 1 snapshot + N parallel updates (no JOIN) + 1 session + 1 final fetch. Для batch из 200 игроков — chunked по 50, чтобы не превышать DB connection pool.
Эволюция: V1 → V2 → V3
| Аспект | V1 (BATCH_UPDATE) | V2 (QUEST_PROGRESS) | V3 (QUEST_PROGRESS_BATCH) |
|---|---|---|---|
| Payload | events[] с typed items | Per-player updates[] | ALL players в одном запросе |
| HTTP requests/min | ~3000 | ~200 (per-player) | 1 (один batch) |
| Matching | Backend: per-type matchers | Plugin: HashSet.Contains | Plugin: HashSet.Contains |
| Backend handlers | 14+ type-specific | 3 per-player | 3 per-player (reused by batch) |
| Sync mechanism | Отдельный /tasks/batch | Отдельный /tasks/batch | Убран — в batch response |
| Immediate sends | Per-player | Per-player + InFlight | Убраны — local detection only |
| Disconnect | Immediate flush | Immediate flush | DisconnectedBuffer → next batch |
| DB queries | 2N+3 sequential | N+3 parallel per-player | N+3 parallel per-player × chunks |
| Rate limit headroom | ~1.3× | ~2× | ~400× |
9. Trade-offs Summary
| Решение | Преимущество | Компромисс |
|---|---|---|
| Push-based HTTP | Единственный возможный паттерн в Oxide | HTTP overhead на каждый запрос |
| Буферизация (60s) | Агрегация: 1 запрос вместо сотен отдельных | Задержка прогресса до 60s в TMA |
| Immediate on completion | Мгновенная фиксация квеста | Concurrent request risk (→ InFlight Guard) |
| Mega-batch | 8.5× меньше запросов | Больший payload per request |
| DLQ + Retry | Устойчивость к network errors | Потеря при >30 мин downtime |
| 60s vs 300s interval | Свежий UX + меньше data loss | Больше запросов чем event-driven |
| V2: Plugin-side matching | -56% кода плагина, -99% backend handlers, O(1) matching | Больше данных в CONNECTED response (~2-5KB match criteria) |
| V2: expand-match-criteria | Плагин не знает бизнес-логику категорий | Backend должен раскрывать категории при каждом CONNECTED |
| V2: Parallel DB updates | N+3 parallel vs 2N+3 sequential queries | Promise.allSettled — нужна обработка partial failures |
| V3: Server-side batching | 201 req/min → 1 req/min, 400× rate limit headroom | Задержка подтверждения completion ≤60s |
| V3: DisconnectedBuffer | Данные не теряются при disconnect | 5 min expiry — данные теряются при долгом простое |
| V3: Crash recovery (PROCESSING) | Re-process при restart — нет потери данных | Дублирование обработки (idempotent handlers) |
| V3: Убраны immediate sends | Проще архитектура — один механизм | Completion подтверждается с задержкой ≤60s |
10. V2 Protocol — Plugin-side Matching
Проблема V1
В V1 плагин отправлял raw events (GATHER, CRAFT, KILL_ANIMAL...) — backend выполнял matching каждого события против всех активных квестов. Это создавало:
- 14+ type-specific handlers на backend (по одному на event type)
- 13 typed буферов в плагине (GatherBuffer, CraftBuffer, KillBuffer...)
- Category sync issues: плагин не знал какие категории backend использует (ORE → metal.ore, sulfur.ore...), что приводило к рассинхрону
- 5877 строк кода плагина
Решение V2: инверсия matching
expand-match-criteria.ts
Ключевой компонент V2 — функция expandMatchCriteria() которая раскрывает бизнес-категории квестов в конкретные значения для plugin matching:
| Event Type | Параметр квеста | Раскрытие |
|---|---|---|
| GATHER | targetResource: "ORE" | resources: ["metal.ore", "sulfur.ore", "hq.metal.ore"] |
| GATHER | gatherMethods: ["ANY_QUARRY"] | methods: ["QUARRY", "EXCAVATOR"] |
| KILL_ANIMAL | killAnimalType: "PREDATOR" | animals: ["BEAR", "POLAR_BEAR", "WOLF", ...] |
| KILL_SCIENTIST | killScientistType: "HEAVY" | scientists: ["OIL_RIG_HEAVY", "BRADLEY_HEAVY"] |
| LOOT_CONTAINER | lootContainerType: "ANY_BARREL" | containers: ["BARREL", "BARREL_BLUE", "BARREL_YELLOW", "OIL_BARREL"] |
| TEA_BREWED | teaType: "healingtea" | teas: ["healingtea", "healingtea.advanced", "healingtea.pure"] |
| * | "ANY" | ["*"] (wildcard — match всё) |
Плагин конвертирует массивы из matchCriteria в HashSet<string>. Matching одного события — один Contains() вызов вместо итерации по всем квестам с complex condition checking.
3 handler'а вместо 14+
V2 backend обрабатывает только 3 типа событий:
| Handler | Когда | Что делает |
|---|---|---|
handlePlayerConnected | Игрок зашёл | Возвращает activeQuests + matchCriteria |
handlePlayerDisconnected | Игрок вышел | Завершает сессию, финальный TIME update |
handleQuestProgress | Mega-batch / immediate | Применяет {userQuestId, increment} параллельно + TIME piggyback через minutesPlayed |
Все type-specific handlers (GATHER, CRAFT, KILL_ANIMAL, LOOT_CONTAINER и т.д.) удалены — matching теперь на стороне плагина. TIME данные не имеют отдельного handler'а — они передаются внутри handleQuestProgress() через поле minutesPlayed.
Обновление match criteria
Match criteria обновляются автоматически при каждом ответе backend:
- CONNECTED — первоначальная загрузка всех activeQuests + matchCriteria
- QUEST_PROGRESS response — содержит обновлённые activeQuests (новые квесты после completion, изменения прогресса)
- Квест завершён — удаляется из activeQuests → плагин убирает его из matching
Если backend добавляет новый квест пользователю (например, через admin panel) между QUEST_PROGRESS запросами — плагин узнает о нём только при следующем response. Задержка ≤60s (один sync цикл).
Related
- Rust Integration — webhook API reference, event types, payload formats
- Data Synchronization — TMA ↔ Backend sync (frontend perspective)
- InFlight Guard ADR — ADR и edge cases InFlight Guard
- Caching Strategy — Redis cache для steamId lookup