Skip to main content

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 requestswebrequest.Enqueue()Async, callback-based, background thread pool
File I/OInterface.Oxide.DataFileSystemЧтение/запись JSON файлов
SQLiteOxide.Core.SQLiteЛокальная база данных
MySQLOxide.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 Bridge

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)

V2 → V3 (Server-Side Batching)

Ключевое изменение: Immediate sends удалены. Все progress updates идут через единый batch каждые 60s. Квест-completion определяется локально на плагине (chat notification), а подтверждается через batch response от backend. Задержка подтверждения ≤60s — приемлемо для TMA.

Путь отправки (Server-Side Batching)

ПутьКогдаЧто отправляетЗадержка
BatchКаждые 60sQUEST_PROGRESS_BATCH: все игроки одним запросом≤60s
Per-playerConnect / DisconnectPLAYER_CONNECTED / PLAYER_DISCONNECTED (lifecycle)~0ms
Disconnect flow

При 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 processingRate limit headroom
5035~3~5 KBTrivial~130×
10070~5~10 KBTrivial~80×
200150~7~20 KBOK (~3s)~57×
500350~12~50 KBOK (~7s, 7 chunks)~33×
Почему req/мин > 1

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
Таймеров811
HTTP req/мин~3000+~350~12~250×
Burst за тик~3000~3501~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
FlushAllBuffers — главная страховка

При любом нормальном disconnect (выход, timeout, kick) FlushAllBuffers принудительно отправляет ВСЕ буферы. Данные теряются только при hard crash сервера.


8. Server-Side Batching: реализация (V3)

Статус: V3 — Server-Side Batching

Эволюция: 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():

  1. Проверяет _isBatchSending (global guard, timeout 30s)
  2. Собирает данные из _activeSessions (ProgressBuffer + AbsoluteBuffer + TIME)
  3. Собирает данные из _disconnectedBuffers (expire >5 min)
  4. Skip если пустой batch (players.Count == 0)
  5. Snapshot time data для accounting on success
  6. Очищает все буферы ДО отправки (prevents duplicates on retry)
  7. Отправляет POST /rust/webhook/batch с payload всех игроков
  8. On success: обновляет time accounting, парсит response (ProcessBatchResponse)
  9. On failure: добавляет BatchPayload в DLQ

Backend: WebhookBatchService

processBatch() в webhook-batch.service.ts:

  1. Дедупликация: INSERT batch log (status=PROCESSING, dedup key=batch:{webhookId}). P2002 + PROCESSED → skip duplicate. P2002 + PROCESSING → re-process (crash recovery)
  2. Resolve users: один findMany WHERE steamId IN [all]Map<steamId, user>
  3. Per-player logs: createMany с eventType=QUEST_PROGRESS, parentBatchId=batchLogId
  4. Chunk processing (50): Promise.allSettledhandleQuestProgress() per-player. Update per-player log status
  5. Finalize: batch log → PROCESSED (или FAILED если все failed) + stats

Per-player handler (переиспользован без изменений)

handleQuestProgress() в webhook-handlers.service.ts:

  1. Snapshot: получает getActiveRustQuests() — метаданные + targetProgress
  2. Строит metaMap: userQuestId → {eventType, targetProgress}
  3. Параллельно (Promise.allSettled) применяет applyProgressIncrement() для каждого update
  4. Skip stale updates (userQuestId не в активном snapshot)
  5. TIME piggyback: если есть minutesPlayed — обновляет TIME квесты
  6. Возвращает completedQuests + обновлённые activeQuests с match criteria
Query budget per player

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)
Payloadevents[] с typed itemsPer-player updates[]ALL players в одном запросе
HTTP requests/min~3000~200 (per-player)1 (один batch)
MatchingBackend: per-type matchersPlugin: HashSet.ContainsPlugin: HashSet.Contains
Backend handlers14+ type-specific3 per-player3 per-player (reused by batch)
Sync mechanismОтдельный /tasks/batchОтдельный /tasks/batchУбран — в batch response
Immediate sendsPer-playerPer-player + InFlightУбраны — local detection only
DisconnectImmediate flushImmediate flushDisconnectedBuffer → next batch
DB queries2N+3 sequentialN+3 parallel per-playerN+3 parallel per-player × chunks
Rate limit headroom~1.3×~2×~400×

9. Trade-offs Summary

РешениеПреимуществоКомпромисс
Push-based HTTPЕдинственный возможный паттерн в OxideHTTP overhead на каждый запрос
Буферизация (60s)Агрегация: 1 запрос вместо сотен отдельныхЗадержка прогресса до 60s в TMA
Immediate on completionМгновенная фиксация квестаConcurrent request risk (→ InFlight Guard)
Mega-batch8.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 updatesN+3 parallel vs 2N+3 sequential queriesPromise.allSettled — нужна обработка partial failures
V3: Server-side batching201 req/min → 1 req/min, 400× rate limit headroomЗадержка подтверждения completion ≤60s
V3: DisconnectedBufferДанные не теряются при disconnect5 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Параметр квестаРаскрытие
GATHERtargetResource: "ORE"resources: ["metal.ore", "sulfur.ore", "hq.metal.ore"]
GATHERgatherMethods: ["ANY_QUARRY"]methods: ["QUARRY", "EXCAVATOR"]
KILL_ANIMALkillAnimalType: "PREDATOR"animals: ["BEAR", "POLAR_BEAR", "WOLF", ...]
KILL_SCIENTISTkillScientistType: "HEAVY"scientists: ["OIL_RIG_HEAVY", "BRADLEY_HEAVY"]
LOOT_CONTAINERlootContainerType: "ANY_BARREL"containers: ["BARREL", "BARREL_BLUE", "BARREL_YELLOW", "OIL_BARREL"]
TEA_BREWEDteaType: "healingtea"teas: ["healingtea", "healingtea.advanced", "healingtea.pure"]
*"ANY"["*"] (wildcard — match всё)
O(1) matching

Плагин конвертирует массивы из matchCriteria в HashSet<string>. Matching одного события — один Contains() вызов вместо итерации по всем квестам с complex condition checking.

3 handler'а вместо 14+

V2 backend обрабатывает только 3 типа событий:

HandlerКогдаЧто делает
handlePlayerConnectedИгрок зашёлВозвращает activeQuests + matchCriteria
handlePlayerDisconnectedИгрок вышелЗавершает сессию, финальный TIME update
handleQuestProgressMega-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:

  1. CONNECTED — первоначальная загрузка всех activeQuests + matchCriteria
  2. QUEST_PROGRESS response — содержит обновлённые activeQuests (новые квесты после completion, изменения прогресса)
  3. Квест завершён — удаляется из activeQuests → плагин убирает его из matching
Stale criteria

Если backend добавляет новый квест пользователю (например, через admin panel) между QUEST_PROGRESS запросами — плагин узнает о нём только при следующем response. Задержка ≤60s (один sync цикл).