219 lines
15 KiB
Markdown
219 lines
15 KiB
Markdown
# Архитектура Indexium - Асинхронный событийный индексатор
|
||
|
||
> Цель: сделать сервис максимально лёгким, дешёвым в обслуживании и устойчивым к ограничениям GitHub.
|
||
> Принцип: бэкенд **не хранит** тяжёлые файлы - артефакты отдаются с GitHub Releases CDN, мы валидируем, индексируем метаданные и выдаём быстрые JSON-ответы.
|
||
|
||
---
|
||
|
||
## 1. Обзор системных компонентов
|
||
|
||
```
|
||
┌───────────────────────────────────────────────┐
|
||
│ GitHub Ecosystem │
|
||
└───────┬───────────────────────────────▲───────┘
|
||
│ │
|
||
│ 1. Webhook (release.published)│ 4. Read Assets / Metadata
|
||
▼ │
|
||
┌─────────────────────────────────────────────────────────┴────────────────────────────────┐
|
||
│ Ваш Бэкенд (Event-Driven Indexer) │
|
||
│ │
|
||
│ ┌───────────────────────┐ ┌──────────────────────┐ ┌─────────────────────┐ │
|
||
│ │ Webhook Ingestion API │ ────> │ Async Job Queue │ ────> │ Worker / Indexer │ │
|
||
│ │ (Fast Signature Check)│ │ (Redis / NATS) │ │ (Jar Parser & AST) │ │
|
||
│ └───────────────────────┘ └──────────────────────┘ └──────────┬──────────┘ │
|
||
│ │ │
|
||
│ ▼ │
|
||
│ ┌───────────────────────┐ ┌──────────────────────┐ ┌─────────────────────┐ │
|
||
│ │ Edge Public REST/v1 │ <──── │ Read-Heavy Cache │ <──── │ Relational DB │ │
|
||
│ │ (High Throughput API) │ │ (Redis Key-Value) │ │ (PostgreSQL + FTS) │ │
|
||
│ └───────────────────────┘ └──────────────────────┘ └─────────────────────┘ │
|
||
└─────────────────────────────────────────────▲────────────────────────────────────────────┘
|
||
│
|
||
│ 5. Query Mods / Index
|
||
│
|
||
┌───────────┴───────────┐
|
||
│ Web UI / Launchers │
|
||
└───────────────────────┘
|
||
```
|
||
|
||
### Потоки данных
|
||
1. **Ingestion** - GitHub шлёт webhook → Ingestion API валидирует HMAC → кладёт job в очередь → отвечает `202`.
|
||
2. **Indexing** - Worker читает job → делает `Range Request` к `.jar` → парсит манифест → пишет в Postgres → инвалидирует кэш.
|
||
3. **Serving** - Лаунчер / Web UI дергает `GET /api/v1/mods` → читаем Redis → miss → Postgres FTS → кэшируем 60s → отдаём JSON.
|
||
|
||
---
|
||
|
||
## 2. Выбор технологического стека
|
||
|
||
| Слой | Технология | Почему именно это? |
|
||
| --- | --- | --- |
|
||
| **Backend Core** | **Rust (Axum)** | Минимальный memory footprint, высокий throughput, быстрый async I/O при парсинге. Альтернатива Go (Fiber) - допустима на Phase 2. |
|
||
| **Main Database** | **PostgreSQL** | Нативный FTS, `JSONB` для зависимостей, `pg_trgm` для fuzzy, `pgvector` опционально для семантики. |
|
||
| **Cache & Queue** | **Redis / Valkey** | Одновременно брокер очередей (Streams/PubSub) и L2-кэш популярных эндпоинтов. |
|
||
| **Worker Engine** | **Rust Background Worker (tokio)** | Потребляет webhook-события, скачивает только zip-header stream, валидирует байткод. |
|
||
| **Auth & Security** | **GitHub App / OAuth 2.0** | Вход только через GitHub, без локальных паролей. |
|
||
| **Frontend** | **SvelteKit + TypeScript** | SSR, маленький бандл, быстрый dev. |
|
||
|
||
> Обоснование против Elasticsearch/Meilisearch на старте: `to_tsvector` + `pg_trgm` переваривают десятки тысяч модов с <5ms без отдельного кластера. Миграция на внешний поиск - только если FTS упрётся.
|
||
|
||
---
|
||
|
||
## 3. Жизненный цикл релиза (Release Lifecycle)
|
||
|
||
### 3.1 Регистрация мода (Onboarding)
|
||
1. Разработчик логинится через GitHub OAuth.
|
||
2. Жмёт «Импортировать репозиторий» → сервис проверяет наличие манифеста (`fabric.mod.json`, `neoforge.mods.toml`, `quilt.mod.json`) в default branch.
|
||
3. Устанавливается GitHub App + Webhook на события `release` и `push`.
|
||
|
||
### 3.2 Обработка webhook (Ingestion & Job Dispatch)
|
||
1. GitHub шлёт `release.published`.
|
||
2. **Ingestion API** валидирует `X-Hub-Signature-256` (HMAC SHA-256 с `WEBHOOK_SECRET`), проверяет идемпотентность по `X-GitHub-Delivery`.
|
||
3. Кладёт задачу в Redis Queue, отвечает `202 Accepted` за <50ms (чтобы не висеть по таймауту GitHub - 10s).
|
||
|
||
### 3.3 Работа воркера-индексатора (Worker Execution) - детальный алгоритм `jar_parser.rs`
|
||
|
||
> Цель: не скачивать весь `.jar` (может быть 20–50MB), а прочитать только нужный манифест через 2–3 Range-запроса.
|
||
|
||
```
|
||
download_url = "https://github.com/owner/repo/releases/download/v1.2.3/mod-1.2.3.jar"
|
||
│
|
||
Step 1: HEAD ─────┤
|
||
▼
|
||
Content-Length: 12345678
|
||
Accept-Ranges: bytes
|
||
(если нет Content-Length → GET Range: bytes=0-0 + парс Content-Range)
|
||
│
|
||
Step 2: GET tail ─┤ Range: bytes=-65536 (последние 64KB)
|
||
▼
|
||
Найти EOCD (End of Central Directory) = 0x06054b50
|
||
Из EOCD: central_dir_offset, central_dir_size, num_entries
|
||
│
|
||
Step 3: GET central dir ─┤ Range: bytes=central_dir_offset..central_dir_offset+size
|
||
▼
|
||
Парс Central Directory headers (0x02014b50)
|
||
Найти entry: fabric.mod.json | quilt.mod.json | neoforge.mods.toml | mcmod.info
|
||
+ icon (assets/<modid>/icon.png если указан в манифесте)
|
||
→ получить local_header_offset, compressed_size
|
||
│
|
||
Step 4: GET manifest ─┤ Range: bytes=local_header_offset.. + compressed_size + header
|
||
▼
|
||
Распаковать (DEFLATE/STORE), парс JSON/TOML, валидация
|
||
+ SHA-256 сверка, file_size = Content-Length
|
||
```
|
||
|
||
**Детали реализации:**
|
||
1. `HEAD` - обязателен, чтобы получить `Content-Length` и убедиться `Accept-Ranges: bytes`. Таймаут 5s, retry 2.
|
||
2. Последние 64KB достаточно для EOCD даже для jar с 10k файлов (EOCD в конце). Если не найден - fallback к последним 128KB.
|
||
3. Central Directory читается одним запросом (обычно 5–30KB). Парсим `central_dir_offset/size` из EOCD.
|
||
4. Манифест - 4-й запрос только если нужен (часто 1–3KB). Иконка - опционально 5-й запрос, кэшируется и отдаётся через `GET /mods/:slug/icon`.
|
||
5. Все `GET` - `reqwest` с `header("Range", ...)`, проверка `206 Partial Content`, иначе fallback к полному скачиванию с лимитом 10MB.
|
||
|
||
**Ошибки:** `412` если `Accept-Ranges` != bytes → full download; `404` на Range → retry без Range; повреждённый ZIP → помечаем `suspicious` и DLQ.
|
||
|
||
Код: `indexium-backend/src/worker/jar_parser.rs` - чистые функции `find_eocd()`, `parse_central_dir()`, `fetch_manifest()` без I/O в тестах.
|
||
|
||
### 3.4 Агрегация и индексация (Storage & Cache Invalidation)
|
||
1. Сохраняет версию в `mod_versions` (см. `database-schema.md`).
|
||
2. `download_url` = прямая ссылка `https://github.com/.../releases/download/...` (CDN `objects.githubusercontent.com`).
|
||
3. Инвалидирует / обновляет Redis-кэш для поиска и карточки мода.
|
||
|
||
---
|
||
|
||
## 4. Обход ключевых ограничений (Edge Cases)
|
||
|
||
### GitHub Rate Limits
|
||
- Запросы воркеров - от имени **GitHub App Installs** (5k–12.5k RPH на инсталл vs 60 RPH анонимных).
|
||
- Скачивание не проксируем - выдаём клиентам прямые CDN-ссылки, трафик не идёт через нас.
|
||
|
||
### Безопасность (Malware Protection)
|
||
- Сверка `SHA-256` ассета с `.sha256` если есть.
|
||
- Сканирование байткода: флаг на `Runtime.getRuntime().exec()`, `ProcessBuilder`, `URLClassLoader`, сетевые вызовы в `<clinit>` / `FabricModInitializer`.
|
||
- Карантин: помечаем версию `suspicious = true`, не показываем в публичном поиске до ручной проверки.
|
||
|
||
### Поиск без Elasticsearch
|
||
- Postgres `to_tsvector('russian'|'english', name || summary)` + `GIN`.
|
||
- `pg_trgm` (`similarity()`, `%` оператор) для опечаток.
|
||
- Материализованный `search_vector` + триггер на update.
|
||
|
||
### Надёжность очереди
|
||
- Retry с exponential backoff (3 попытки), DLQ (dead-letter) для ручного разбора.
|
||
- Идемпотентный воркер: `ON CONFLICT (mod_id, version_number) DO UPDATE`.
|
||
|
||
### Телеметрия (bStats аналог, см. docs/analytics.md)
|
||
- **Ingestion:** `POST /api/v1/analytics/submit` (gzip JSON, без IP логов) → валидация allow-list → `server_hash = sha256(uuid + daily_salt)` → Redis rate limit 1/15мин → `INSERT mod_telemetry_pings`.
|
||
- **Aggregation:** кроном раз в час `COUNT(DISTINCT server_hash)` + `breakdown_json` → `mod_daily_stats`, TTL 30 дней для сырых пингов (`DELETE WHERE pinged_at < NOW()-30d`).
|
||
- **Serving:** `GET /mods/:slug/analytics?range=30d` (кэш 5 мин) + `GET /badges/:slug/servers.svg` + `sort=active_servers` для честной сортировки по реальным установкам, а не накрученным скачиваниям.
|
||
- **Масштаб:** Postgres хватает до 10M пингов/мес, далее TimescaleDB hypertable без смены схемы.
|
||
|
||
---
|
||
|
||
## 5. Схема структуры БД (Core Entity Relation)
|
||
|
||
См. детально в [`database-schema.md`](database-schema.md). Коротко:
|
||
|
||
```sql
|
||
-- Таблица модов
|
||
CREATE TABLE mods (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
github_repo_id BIGINT UNIQUE NOT NULL,
|
||
slug VARCHAR(64) UNIQUE NOT NULL,
|
||
name VARCHAR(128) NOT NULL,
|
||
summary TEXT,
|
||
author_github_id BIGINT NOT NULL,
|
||
default_branch VARCHAR(32) DEFAULT 'main',
|
||
created_at TIMESTAMPTZ DEFAULT now()
|
||
);
|
||
|
||
-- Таблица версий (релизов)
|
||
CREATE TABLE mod_versions (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
mod_id UUID REFERENCES mods(id) ON DELETE CASCADE,
|
||
version_number VARCHAR(32) NOT NULL,
|
||
game_versions VARCHAR(32)[] NOT NULL, -- e.g. ['1.20.1', '1.20.2']
|
||
loaders VARCHAR(16)[] NOT NULL, -- e.g. ['fabric', 'quilt']
|
||
download_url TEXT NOT NULL, -- GitHub Release Direct Asset URL
|
||
file_sha256 CHAR(64) NOT NULL,
|
||
published_at TIMESTAMPTZ NOT NULL,
|
||
UNIQUE(mod_id, version_number)
|
||
);
|
||
|
||
CREATE INDEX idx_versions_lookup ON mod_versions USING GIN (game_versions, loaders);
|
||
```
|
||
|
||
Дополнительно: `authors`, `webhook_deliveries` (идемпотентность), `search_vector`, `mod_telemetry_pings`/`mod_daily_stats` (аналитика).
|
||
|
||
---
|
||
|
||
## 6. Масштабирование и эволюция
|
||
|
||
| Этап | Нагрузка | Действие |
|
||
|------|----------|----------|
|
||
| MVP | <10k модов, <100 RPS | Один инстанс Axum + Postgres + Redis, воркер в том же бинаре (tokio spawn) |
|
||
| Growth | 10k-100k модов, 1k RPS | Вынос воркера в отдельный деплой, реплика Postgres RO, Redis Cluster |
|
||
| Scale | 100k+ модов, 10k RPS | NATS JetStream вместо Redis Streams, read-replica + шардирование по `slug`, CDN перед API (Cloudflare) |
|
||
|
||
---
|
||
|
||
## 7. Нефункциональные требования
|
||
|
||
- **Latency**: `GET /mods` p95 < 80ms (cache hit), < 200ms (cache miss + FTS).
|
||
- **Availability**: 99.9% (допустим 43 мин downtime/мес на MVP).
|
||
- **Cost**: < $20/мес на MVP (1 VPS Hetzner + managed Postgres free tier).
|
||
- **Security**: HMAC, GitHub App, no local passwords, malware scan.
|
||
|
||
---
|
||
|
||
## 8. Диаграмма деплоя (MVP)
|
||
|
||
```
|
||
[GitHub] --webhook--> [Axum Ingestion :3000] --> [Redis :6379] --> [Worker]
|
||
| |
|
||
v v
|
||
[Postgres :5432] <--+
|
||
|
|
||
[Browser/Launcher] --> [Axum Public API :3000] --> [Redis Cache]
|
||
\
|
||
--> [SvelteKit :5173] (SSR, fetch API)
|
||
```
|
||
|
||
Все сервисы - `docker compose` локально, один VPS в проде.
|