Распределённый узел хранения DAG-графа транзакций поверх консенсуса Raft.
Лицензия: MIT OR Apache-2.0 (см. LICENSE-MIT и LICENSE-APACHE).
Каждая транзакция — узел графа, ссылающийся на родителей (хэши других транзакций).
Узлы реплицируются через Raft; «утяжелённые» узлы архивируются в Redis и удаляются из
оперативного DAG. Транзакция подписывается ключом ed25519, а её хэш покрывает всё
содержимое, включая func.
Статус: ранний MVP / экспериментальный прототип. Внутренний проект.
- Rust
- openraft 0.10 — консенсус Raft (git databendlabs)
- axum 0.7 + tokio — HTTP-сервер и асинхронный runtime
- redb — персистентное хранение Raft-лога и state machine (чистый Rust)
- redis 0.32 — архив подтверждённых (удалённых из DAG) узлов
- blake3 / ed25519-dalek / base58 — хэширование и подписи
- serde / serde_json, rayon, clap, dotenvy, tracing
src/
├── main.rs точка входа: логи, конфиг, запуск Raft/HTTP, фоновой очистки
├── config.rs AppConfig: чтение CLI + env (dotenvy), профили, таймауты, секреты
├── lib.rs TypeConfig, тип Tx, start_raft (сборка узла)
├── server.rs HTTP-хендлеры; публичный/внутренний API, наблюдаемость, rate limit
├── auth.rs middleware проверки кластерного токена (x-internal-token)
├── web.rs внутренний HTTP-клиент (пересылка на лидера, с токеном)
├── processor.rs фоновая очистка «тяжёлых» узлов (ведёт лидер)
├── cleanup.rs выбор кандидатов/тяжёлых узлов, архивация в Redis
├── metrics.rs реестр метрик Prometheus
├── ratelimit.rs token-bucket для публичного API
├── shutdown.rs graceful shutdown
├── audit.rs аудит-события add/remove/membership
├── graph/
│ ├── dag.rs DAG: узлы, родители/дети, реестр added, TxVar и валидация
│ └── weights.rs глубины и веса узлов (BFS, rayon)
└── raft/
├── command.rs команды Request::{Add, Remove} и Response
├── log.rs PersistentLogStore (redb) + in-memory LogStore (тесты)
├── store.rs state machine: валидация, персистентность, версия схемы
├── db.rs обёртка над redb (таблицы meta/logs)
├── network.rs сетевой слой Raft (append/vote/snapshot)
├── router.rs HTTP-транспорт для Raft RPC
└── api.rs хендлеры /raft/* и /mng/*
benches/
└── graph_bench.rs criterion-бенчмарки графа
src/bin/
└── loadgen/ нагрузочный генератор (N01): кластер, генератор, драйвер, отчёт
scripts/
├── backup.sh бэкап DATA_DIR + Redis
├── restore.sh восстановление из бэкапа
└── loadgen.sh обёртка нагрузочного прогона
Поток записи. Клиент шлёт подписанную транзакцию на POST /. Узел проверяет
родителей, func, структуру var, вычисляет канонический хэш, проверяет подпись и
пересылает запрос лидеру (если сам не лидер). Лидер пишет команду в Raft; state machine
детерминированно валидирует её на каждой реплике и применяет к DAG.
- Детерминированный state machine: одинаковая команда → одинаковое состояние.
funcвходит в подписываемый и хэшируемый контент.- Хэш узла уникален; повторная вставка запрещена.
- Удалённые узлы попадают в реестр
addedи переживают снапшот/рестарт. - Публичный API отделён от внутреннего (Raft/mng).
cargo build --release
# либо
./build.shБинарник: target/release/dagdb.
Скопируйте шаблон и заполните значения (файл .env в .gitignore):
cp .env.example .envПриоритет: аргумент CLI → переменная окружения → значение по умолчанию.
Секреты (REDIS_URL, INTERNAL_API_TOKEN) обязательны и задаются только из окружения.
| Переменная | CLI | По умолчанию | Назначение |
|---|---|---|---|
NODE_ID |
--id |
1 |
Уникальный ID узла в кластере |
APP_PROFILE |
--profile |
dev |
Профиль окружения: dev/stage/prod |
LOG_FORMAT |
--log-format |
text |
Формат логов: text/json |
ADVERTISE_ADDR |
--advertise-addr |
— | Адрес узла, публикуемый кластеру (host:port) |
ADVERTISE_HOST |
--addr |
127.0.0.1 |
Хост для ADVERTISE_ADDR, если адрес не задан явно |
BIND_ADDR |
--bind-addr |
0.0.0.0:21001 |
Адрес прослушивания HTTP (см. приоритет ниже) |
HTTP_PORT |
--port |
— | Порт HTTP; перекрывает BIND_ADDR (F2) |
DATA_DIR |
--data-dir |
./data |
Каталог персистентных данных |
REDIS_URL |
--redis-url |
— (обязательно) | Строка подключения к Redis |
INTERNAL_API_TOKEN |
--internal-api-token |
— (обязательно) | Кластерный токен внутреннего API |
TRUST_PROXY |
--trust-proxy |
false |
Доверять X-Forwarded-For в rate limit (только за доверенным прокси) |
RUST_LOG |
— | info |
Уровень логов |
HTTP_TIMEOUT_SECS |
— | 10 |
Таймаут HTTP-запросов приложения |
HTTP_CONNECT_TIMEOUT_SECS |
— | 3 |
Таймаут установки HTTP-соединения |
RAFT_HTTP_TIMEOUT_SECS |
— | 30 |
Таймаут Raft HTTP-запросов |
RAFT_CONNECT_TIMEOUT_SECS |
— | 10 |
Таймаут установки Raft HTTP-соединения |
REDIS_CONNECT_TIMEOUT_SECS |
— | 5 |
Таймаут подключения к Redis при старте |
PROCESSOR_INTERVAL_MS |
— | 250 |
Интервал фоновой очистки «тяжёлых» узлов |
WEIGHT_THRESHOLD |
— | 0.5 |
Порог веса (после насыщения) для удаления узла |
CLEANUP_BATCH_SIZE |
— | 100 |
Размер батча кандидатов на очистку за цикл |
PUBLIC_RATE_LIMIT_PER_SEC |
— | 50 |
Лимит запросов/с на публичный POST / (0 — выключено) |
PUBLIC_RATE_LIMIT_BURST |
— | 100 |
Всплеск для rate limiter |
MAX_REQUEST_BYTES |
— | 1048576 |
Advisory-лимит размера тела запроса |
В DATA_DIR создаются raft-log.redb (Raft-лог, vote, committed) и
state-machine.redb (DAG, added, membership, снапшот).
Приоритет адреса прослушивания (F2): явный HTTP_PORT/--port перекрывает BIND_ADDR
(с предупреждением в логе), поэтому .env с общим BIND_ADDR не мешает запускать узлы на разных
портах. BIND_ADDR, заданный в процессе/CLI без HTTP_PORT, используется как есть. Если не задано
ни то, ни другое — 0.0.0.0:21001.
docker compose up --buildПоднимаются Redis и 4 узла (node1..node4, порты 21001..21004).
-
Запустите Redis с паролем, совпадающим с
REDIS_URLв.env(значениеCHANGE_MEв.env.exampleзамените на свой пароль):docker run -d --name dagdb-redis -p 6379:6379 redis:7-alpine \ redis-server --requirepass "$REDIS_PASSWORD"Пароль подставляется из переменной
REDIS_PASSWORD; строка в.envимеет видREDIS_URL=redis://:$REDIS_PASSWORD@localhost:6379/0. Если Redis запущен без пароля, используйте URL без части пароля:REDIS_URL=redis://localhost:6379/0(иначе узел завершится ошибкой подключения). -
Запустите 4 узла (каждый — в своём терминале) с разными
NODE_ID/портами:NODE_ID=1 HTTP_PORT=21001 DATA_DIR=./data/node1 ./target/release/dagdb NODE_ID=2 HTTP_PORT=21002 DATA_DIR=./data/node2 ./target/release/dagdb NODE_ID=3 HTTP_PORT=21003 DATA_DIR=./data/node3 ./target/release/dagdb NODE_ID=4 HTTP_PORT=21004 DATA_DIR=./data/node4 ./target/release/dagdb
(Строка
REDIS_URLиINTERNAL_API_TOKENберутся из.env.) Не помещайтеNODE_ID/BIND_ADDR/HTTP_PORT/ADVERTISE_ADDR/DATA_DIRв общий.env: он читается из рабочего каталога всеми узлами. В частности, оставшийся в.envADVERTISE_ADDRнавяжет всем узлам один адрес. -
Инициализируйте кластер и загрузите генезис (см. примеры ниже). Готовый сценарий —
test.sh: поднимает 4 узла, инициализирует кластер, грузитgenesis.jsonи делает проверки.
| Метод | Путь | Описание |
|---|---|---|
POST |
/ |
Принять подписанную транзакцию |
GET |
/pool?limit=&offset= |
Узлы-кандидаты (пагинация, по умолчанию limit=100, максимум 1000) |
GET |
/full?limit=&offset= |
Граф с глубинами (пагинация, те же лимиты) |
GET |
/metrics |
Метрики Prometheus |
GET |
/health |
Liveness |
GET |
/ready |
Readiness (лидер выбран, Redis достижим) |
GET |
/openapi.json |
OpenAPI-спецификация публичного API |
GET |
/docs |
Swagger UI |
Полное описание схемы Tx/var/func и правил валидации — в
docs/api/API.md.
| Метод | Путь | Описание |
|---|---|---|
POST |
/add |
Запись транзакции (внутрикластерная пересылка) |
POST |
/remove_heavy_nodes |
Архивировать и удалить «тяжёлые» узлы |
POST |
/load-genesis |
Загрузить genesis.json |
POST |
/raft/vote, /raft/append, /raft/snapshot |
Raft RPC |
POST |
/mng/init |
Инициализация кластера |
POST |
/mng/add-learner |
Добавить learner-узел |
POST |
/mng/change-membership |
Изменить состав кластера |
POST |
/mng/metrics |
Метрики Raft |
POST |
/mng/snapshot |
Принудительно построить снапшот (перед бэкапом) |
Инициализация кластера из 4 узлов:
curl -X POST -H "Content-Type: application/json" \
-H "x-internal-token: $INTERNAL_API_TOKEN" \
-d '[[1,"127.0.0.1:21001"],[2,"127.0.0.1:21002"],[3,"127.0.0.1:21003"],[4,"127.0.0.1:21004"]]' \
http://127.0.0.1:21001/mng/initЗагрузка генезиса:
curl -X POST -H "Content-Type: application/json" \
-H "x-internal-token: $INTERNAL_API_TOKEN" -d '[]' \
http://127.0.0.1:21001/load-genesisПубличный граф:
curl http://127.0.0.1:21001/fullТранзакция (POST /) имеет вид:
{
"tx": {
"prnts": ["<hash-родителя-1>", "<hash-родителя-2>"],
"addr": "<ed25519 pubkey в base58>",
"seq": 0,
"var": { "ca": "0", "to": "<base58>", "val": 100, "msg": "optional" }
},
"sign": "<ed25519-подпись в hex, 64 байта>",
"func": "transferToken"
}Хэш — blake3 от канонической JSON-сериализации tx вместе с func
(ключи рекурсивно отсортированы). Подпись — ed25519 над байтами хэша.
Внутренние вызовы (/add) не требуют публичной подписи повторно: узел проверяет
переданный hash на совпадение с вычисленным.
Поле seq монотонно в рамках адреса (анти-replay). Один аккаунт клиент обязан
сериализовать: следующий seq отправляется только после ответа на предыдущий;
конкурентные запросы шлите от разных адресов (их seq независимы).
- Публичный API (
/,/pool,/full) доступен без аутентификации. - Все внутренние эндпоинты (
/add,/remove_heavy_nodes,/load-genesis,/raft/*,/mng/*) требуют кластерный токенINTERNAL_API_TOKENв заголовкеx-internal-token. Токен передаётся и внутрикластерными клиентами (Raft RPC, пересылка на лидера, очистка), поэтому кластер работает как единое доверенное пространство. - Адрес прослушивания задаётся
BIND_ADDR/HTTP_PORT; хардкода0.0.0.0в коде нет. - TLS между узлами не реализован. Внутрикластерный трафик идёт по HTTP и защищён общим токеном. Шифрование канала (rustls/mTLS) — задача внешнего уровня (service mesh/VPN) или отдельного этапа развития; при развёртывании в недоверенной сети выносите узлы в приватный сегмент.
GET /metrics— метрики в формате Prometheus text exposition: состояние Raft (term, индексы, очередь применения, лидер, узлы), размер DAG и реестраadded, порог веса, счётчики очистки, транзакций и HTTP (с гистограммой длительности). InfluxDB2/VictoriaMetrics/Telegraf подключаются внешним сборщиком (inputs.prometheusилиremote_write), изменений в узле не требуется.GET /health— liveness;GET /ready— readiness (есть лидер, Redis достижим).- Логи структурированы:
LOG_FORMAT=jsonвключает однострочный JSON, каждый HTTP-запрос получает корреляционныйx-request-id(возвращается в ответе). - Аудит значимых операций (add/remove/membership) пишется в лог с
target="audit"и указанием источника (api/processor/genesis/management).
По Ctrl-C/SIGTERM узел перестаёт принимать запросы
(with_graceful_shutdown), затем финализирует Raft (Raft::shutdown) — состояние
сохраняется в DATA_DIR (redb) и переживает рестарт.
Бэкап (свежий снапшот + копия DATA_DIR + дамп Redis):
NODE_URL=http://127.0.0.1:21001 DATA_DIR=./data/node1 \
INTERNAL_API_TOKEN=$INTERNAL_API_TOKEN REDIS_URL=$REDIS_URL \
./scripts/backup.sh # создаст ./backups/<timestamp>/Восстановление (узел должен быть остановлен):
BACKUP_SNAPSHOT=./backups/<timestamp> DATA_DIR=./data/node1 ./scripts/restore.shcargo benchБенчмарки compute_descendants_with_depth_and_weight (500 узлов),
get_nodes_by_depth (1000 узлов) и add_node_with_parents (throughput записи).
CI проверяет компиляцию бенчмарков (cargo bench --no-run), не запуская их.
Базовые числа (debug-хост, ориентир, не SLA): обход потомков 500 узлов —
~8.6 мс; get_nodes_by_depth на 1000 узлов — ~0.41 мс; одна вставка узла
(add_node_with_parents, включая сериализацию JSON) — ~3.6 мкс (~280k вставок/с
на поток).
loadgen — отдельный рантайм-инструмент (вне cargo test), который поднимает
in-process кластер dagdb, гонит валидный поток подписанных транзакций через
POST / и проверяет корректность: все валидные транзакции приняты, а итоговый
размер DAG одинаков на всех репликах. Throughput и перцентили задержки печатаются
как информационные (жёсткого SLA нет).
Каждый запрос уходит на случайный узел кластера: не-лидер сам перенаправляет
запись лидеру. Родители транзакции берутся из /pool того же узла, чтобы его
ранняя проверка состояния (validate_against_state) не отклонила валидную
транзакцию из-за отставания реплики.
# Обёртка сама обеспечивает Redis (поднимет контейнер, если недоступен),
# собирает release и запускает короткий прогон.
./scripts/loadgen.sh
# Параметры прогона
./scripts/loadgen.sh --tx 2000 --tps 1000 --parents 3
./target/release/loadgen --nodes 3 --tx 500 --tps 500 --concurrency 8 \
--accounts 16 --parents 2 --seed 1 --json-out report.json| Флаг | По умолчанию | Назначение |
|---|---|---|
--nodes |
3 |
Число in-process узлов кластера |
--tx | --duration-sec |
--duration-sec 60 |
Число транзакций либо длительность прогона |
--tps |
— | Целевой суммарный TPS (0 — без ограничения) |
--concurrency |
— | Число конкурентных sender-задач |
--accounts |
1 |
Число аккаунтов-отправителей |
--parents |
2 |
Родителей на транзакцию (`2..=min(100, |
--seed |
1 |
Seed детерминированного ГПСЧ |
--show-tx |
true |
Наглядный пофреймовый вывод запроса/ответа (в демо включён) |
--sleep |
— | Фиксированная пауза между запросами, с (0 — без паузы) |
--json-out |
— | Путь JSON-отчёта (артефакт CI) |
--keep-data |
false |
Не удалять временные каталоги данных узлов |
--verbose |
false |
Подробные логи (debug; по умолчанию info, openraft-шум приглушён) |
Обёртка scripts/loadgen.sh берёт REDIS_URL из окружения или .env; если Redis недоступен,
поднимает контейнер dagdb-redis (docker rm -f + docker run, пароль для --requirepass — из
REDIS_URL). Повторные запуски идемпотентны.
Запуск без параметров включает наглядный «живой» прогон: --show-tx (пофреймовые
цветные блоки «шапка → запрос → ответ» в stdout), случайную паузу 0.1..2.5 с между
запросами, --concurrency 1 (ровный поток) и ограничение по времени --duration-sec 60
(вместо бюджета --tx 500), TPS при этом не ограничивает. Любой явный флаг выключает
авто-демо и возвращает обычные дефолты.
# Демо: ~1 минута медленного наглядного потока
./target/release/loadgen
# Быстрый прогон с выводом (без паузы)
./target/release/loadgen --show-tx --sleep 0 --tx 30
# Фиксированная пауза 0.5 с между запросами
./target/release/loadgen --show-tx --sleep 0.5 --tx 50Цвета включаются только когда stdout — терминал, и отключаются при переменной NO_COLOR
(новых зависимостей нет). Пофреймовый вывод идёт только при --show-tx; --json-out и
CI-прогон блоков не печатают и не замедляются. В демо-режиме строка «параметры» в итоговом
отчёте не печатается (фактические параметры задаются демо-дефолтами, а не флагами).
В пофреймовом выводе виден узел-приёмник и его роль: → POST http://127.0.0.1:45107 (follower → лидер) func=transferToken или (лидер). Узлы выбираются случайно, поэтому
блоки упражняют и прямой путь (запись на лидера), и перенаправление через follower.
Поле worker в шапке блока показывается только при --concurrency > 1 (в одиночном
режиме оно было бы всегда worker 0).
Логи по умолчанию — уровня info: при записи транзакций узлы печатают человекочитаемые
события вида нода 2 получила транзакцию 9fbc198d…3d4b, нода 2 переправила транзакцию … на адрес …, нода 1 записала транзакцию … («записала» — на каждой реплике). В loadgen эти
события идут в stderr компактным форматом, поэтому не смешиваются с пофреймовым выводом и
отчётом в stdout. Внутренний openraft-шум bootstrap приглушён (openraft=error). --verbose
включает debug; RUST_LOG (если задан) имеет приоритет.
Генерация полностью опирается на домен проекта (тот же ordered_sum, Tx,
TRANSFER_TOKEN), поэтому не расходится с правилами state machine. Очистка
«тяжёлых» узлов и rate limit на время прогона выключены — иначе родители
«уезжают» в added, а часть запросов ловит 429.
Корректностный результат — код возврата 0/не 0: приняты все валидные
транзакции и размер DAG совпал на репликах. Отдельная CI-задача load
(workflow_dispatch/nightly) запускает короткий прогон и падает при нарушении
инвариантов, не удлиняя PR-пайплайн.
Ограничения: узлы и нагрузка делят одну машину, поэтому TPS — не эталонный; инструмент предназначен для проверки корректности и регрессий, а не для сравнительных замеров производительности.
cargo fmt --all -- --check
cargo clippy --all-targets
cargo test --allCI (.github/workflows/ci.yml) выполняет fmt + clippy -D warnings + build + test.
- Внутрикластерный трафик идёт без TLS; защита — общий кластерный токен.
- Публичная регистрация узлов/membership не предусмотрена: управление только внутренним API с токеном.
- Rate limiter и метрики — в памяти узла (без внешнего хранилища); при
необходимости горизонтального сбора используйте
/metricsи внешний сборщик.
Карта документации — docs/README.md. Профили конфигурации: dev (по
умолчанию), stage (токен ≥16 символов), prod (токен ≥32 символов и явный
ADVERTISE_ADDR); при старте конфигурация валидируется и все проблемы выводятся
одним сообщением.