Document shared history Merkle protocol and bounded transactions

This commit is contained in:
evgeny committed 2026-10-26 18:27:26 +01:00
1 parent df39999624
commit 669ad86e57
2 files changed
+185 -563

No files matched your search

+165 -558
View File
@@ -1,558 +1,165 @@
# db_sync — децентрализованная реплицируемая таблица с цепным хешированием
Аудит 26.10.2026 частичный. Исправлены удаление корректной истории при отказе
проверки/неверном входящем повторе и SQL доставки. ACK_PUSH теперь содержит
message_id; старый формат не поддерживается. Условия удаления, проверки и
оставшиеся границы аудита — [отчёт, разделы10–11](../../doc/member_merkle_audit_findings.txt).
## 1. Архитектура
### 1.1 Обзор
```
UTUN_INSTANCE
│
┌────▼────┐
│ DB_SYNC │ (глобальный контекст, один на инстанс)
└────┬────┘
│
┌────────────────┼────────────────┐
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│DB_SYNC │ │DB_SYNC │ │DB_SYNC │
│INSTANCE │ │INSTANCE │ │INSTANCE ... │
│msg_GENERAL │ │msg_SECRET │ │ │
└──────┬───────┘ └──────┬───────┘ └──────────────┘
│ │
┌────────┼────────┐ │
▼ ▼ ▼ ▼
┌────────┐┌────────┐┌──────┐
│SI_PEER ││SI_PEER ││SI_...│ (per-instance peer state)
│node_A ││node_B ││ │
└────────┘└────────┘└──────┘
```
**DB_SYNC** — глобальный модуль (struct, один на UTUN_INSTANCE):
- `inst` — указатель на UTUN_INSTANCE
- `db` — SQLite-соединение (своё или shared `inst->topo_sqlite_db`)
- `instances[]` — динамический массив DB_SYNC_INSTANCE
- `peer_check_timer` — периодическая проверка (5с), ищет несинхронизированных пиров
- `done_cbks` — связанный список коллбэков завершения синхронизации
**DB_SYNC_INSTANCE** — одна таблица синхронизации:
- `table_name` — имя SQLite-таблицы (например `msg_GENERAL`)
- `group_id` — идентификатор группы для маршрутизации сообщений
- `sync_gated` — ожидание успешной синхронизации участников; блокирует входящий и исходящий обмен
- `next_id` — автоинкрементный ID записей
- `last_timestamp_ms` — последний использованный timestamp (монотонный)
- `peers[]` — динамический массив SI_PEER (состояние синхронизации с каждым пиром)
- `recalc` — позиция, pending и таймер пересчёта; ошибка сохраняет pending, батчи по50
- `ttl_timer` — периодическая TTL-очистка (каждый час)
- `on_insert` / `on_insert_arg` — коллбэк при вставке записи
**SI_PEER** — состояние пира в контексте инстанса:
- `node_id` — идентификатор пира
- `synced_pos` — позиция (0-based) до которой данные синхронизированы
- `verified_pos` — последняя подтверждённая позиция (совпадение хешей)
- `last_peer_count` — последнее известное количество записей у пира
- `sync_state` — 0=idle, 1=syncing, 2=synced
- `sync_start_tb` — время начала/последнего прогресса обмена; устанавливается в обеих ролях
- `retry_count` — число повторов после таймаута
### 1.2 Структура данных (SQLite)
Каждый инстанс — это SQLite-таблица:
```sql
CREATE TABLE "msg_GENERAL" (
timestamp INTEGER NOT NULL, -- мс, монотонный (NTP или local)
node_id INTEGER NOT NULL, -- author node_id (8 байт)
id INTEGER NOT NULL, -- автоинкремент внутри инстанса
chain_hash BLOB NOT NULL, -- SHA256(prev_hash || id || ts || author || sig)
flags INTEGER DEFAULT 0, -- DB_REC_FLAG_WAS_SENT (0x01)
data BLOB, -- JSON-данные записи
author_signature BLOB NOT NULL, -- Ed25519(ts || data), 64 байта
message_id BLOB NOT NULL UNIQUE CHECK(length(message_id)=32),
local_attrs TEXT DEFAULT '', -- локальные атрибуты (не реплицируются)
delivered_peers INTEGER DEFAULT 0, -- счётчик пиров, принявших PUSH
delivery_chain TEXT DEFAULT '', -- цепочка peer_id hex через запятую
PRIMARY KEY (timestamp, author_signature)
);
CREATE INDEX idx_msg_GENERAL_ttl ON msg_GENERAL (node_id, timestamp);
```
**Цепной хеш:**
```
chain_hash[pos] = SHA256(chain_hash[pos-1] || id || timestamp || author || author_signature)
chain_hash[0] = SHA256(0x00..00 [32] || id_0 || ts_0 || author_0 || sig_0)
```
Упорядочение: `ORDER BY timestamp, author_signature`.
### 1.3 Ключевые механизмы
| Механизм | Описание |
|----------|----------|
| **Цепной хеш** | Каждая запись ссылается на хеш предыдущей — защита от вставки/удаления в середине цепочки |
| **Ed25519-подписи** | Каждая запись подписана автором. Проверяется при вставке (и локальной, и от пира) |
| **Async recalc** | Батчи по50 с атомарным продвижением после COMMIT; перед обменом flush завершается синхронно, ошибка прерывает раунд |
| **Master/Slave** | Узел с бОльшим `node_id` — мастер (шлёт `INIT_SYNC`). Узел с меньшим — слейв (шлёт `REQUEST_SYNC`) |
| **Периодическая проверка** | Каждые 5 секунд — поиск пиров с sync_state=0 и запуск синхронизации, детекция таймаутов (15с) |
| **TTL-очистка** | Каждый час — удаление неподтверждённых (`flags&1==0`) записей локального автора старше `db_sync_ttl` |
| **Lazy-register** | При получении сообщения с неизвестным instance_hash — поиск таблицы `msg_<ch_id>` с совпадающим хешем |
| **Push** | Новая локальная запись немедленно рассылается всем пирам в sync_state >= 1 |
| **Delivery tracking** | `delivered_peers` / `delivery_chain` — отслеживание доставки PUSH каждому пиру |
### 1.4 Интеграция с ETCP
- **Service ID:** `ETCP_ID_DB_SYNC = 0x20`
- **Биндинг:** `etcp_bind(inst, ETCP_ID_DB_SYNC, db_sync_recv_cb)` — приём сообщений
- **Connection callbacks:** `etcp_add_conn_status_cbk(inst, db_sync_on_conn_status, NULL)` — отслеживание UP/DOWN соединений
- **Отправка:** через `etcp_send()` в прямое P2P-соединение
- **Маршрутизация:** каждый пакет начинается с `[svc:1][hash_be:8]`, hash идентифицирует инстанс на приёмной стороне
---
## 2. Диаграмма состояний синхронизации
### 2.1 Состояния пира (sync_state)
```
┌─────────────────────────────────────────────┐
│ │
▼ │
┌───────┐ INIT_SYNC/REQUEST_SYNC ┌─────────┐ │
│ IDLE │ ──────────────────────────► │ SYNCING │ │
│ (0) │ │ (1) │ │
└───────┘ └────┬────┘ │
▲ │ │
│ ┌─────────┼──────┘
│ │ │
│ ┌───────────▼──┐ ┌───▼──────────┐
│ │ SYNC_DONE │ │ TIMEOUT+ERROR │
│ │ matched ✓ │ │ (15s) │
│ │ (2) │ │ reset → IDLE │
│ └──────────────┘ └───────────────┘
│ │
└─────────────────────────────────────────────┘
потеря READY группы / ERROR (NOT_FOUND, DISABLED, NOT_READY)
```
### 2.2 Диаграмма протокола синхронизации
```
MASTER (node_id > peer) SLAVE (node_id < peer)
─────────────────────── ──────────────────────
участники синхронизированы + READY группы → initiate sync
│
├─ INIT_SYNC ──────────────────────────►
│ [my_count:4] │ flush recalc
│ │ определяет tp = min(peer_c, my_c) - 1
│ │ вычисляет ch8_at_tp
│ │ строит sparse hashes (интервалы: 1,1,1,2,2,4×8)
│ │
│ ◄────────────────────────── INIT_RESP │
│ [tp:4][ch8:8][has_tail:1] │
│ [sparse_cnt:1][(pos,ch8)*] │
│ │
│ flush recalc │
│ ┌─ peer ch8 == 0? ──► send ALL data │
│ ├─ ch8 match + no_tail? ──► DONE │
│ ├─ ch8 match + has_tail? ──► send │
│ │ from tp+1, want_from=tp+1 │
│ └─ ch8 mismatch? ──► находим │
│ первую разницу через sparse │
│ hashes → fm (first mismatch) │
│ vp = fm-1, send from fm │
│ │
│ SEND_DATA ──────────────────────────► │
│ [from:4][count:2][vp:4] │ вставляет записи, проверяет подписи
│ [want_from:4][records...] │ проверяет want_from:
│ │ want≠NONE → отправляет встречный SEND_DATA
│ │ want=NONE + ss=1 → SYNC_DONE
│ │
│ ◄────────────────────────── SEND_DATA │
│ (встречные данные при want→NONE) │
│ │
│ ┌─ want=NONE + ss=1 → SYNC_DONE ────►│
│ │ [mc:4][ch8_last:8] │ сверяет mc==pc и ch8==pch8
│ │ │ match → ss=2, DONE
│ │ │ mc<pc → request tail
│ │ │ mc>pc → send tail
│ └─────────────────────────────────────│
```
### 2.3 Временная диаграмма жизненного цикла
```
┌─ init ─────────────────────────────────────────────────────────────────────┐
│ db_sync_init(inst) │
│ ├─ открывает SQLite (shared или свой chats.db) │
│ ├─ etcp_bind(inst, 0x20, recv_cb) │
│ └─ таймер повторов запускается при появлении работы │
│ │
│ ... позже, при создании канала ... │
│ │
│ db_sync_instance_add(inst, "msg_GENERAL", group_id) │
│ ├─ CREATE TABLE IF NOT EXISTS... │
│ ├─ загружает next_id = MAX(id)+1 │
│ ├─ db_verify_chain() — проверка целостности цепных хешей │
│ ├─ запускает TTL-таймер (1 час) │
│ └─ подписка на READY группы; gated=1, сетевого обмена нет │
├────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─── DB_SYNC_INSTANCE активен ───────────────────────────────────────────┐│
│ │ ││
│ │ member_sync OK + READY группы → initiate_sync() ││
│ │ └─ пир должен быть участником именно этой группы ││
│ │ ││
│ │ потеря READY (DOWN/REINIT/LEAVE/ошибка отправки снимка) ││
│ │ └─ сброс состояния обмена только этой групповой сессии ││
│ │ ││
│ │ recv_cb() — приём сообщений ││
│ │ ├─ извлекает group_id, type, payload ││
│ │ ├─ проверяет регистрацию, членство, gated и READY ││
│ │ └─ диспетчеризация по type ││
│ │ ││
│ │ peer_check_timer (каждые 5с) ││
│ │ ├─ находит пиров c sync_state=0, запускает sync ││
│ │ └─ детектит таймауты (15s), сбрасывает sync_state ││
│ │ ││
│ │ ttl_timer (каждый час) ││
│ │ └─ удаляет неподтверждённые локальные записи старше TTL ││
│ │ ││
│ │ recalc_timer (асинхронно, батчи по 50) ││
│ │ └─ пересчитывает chain_hash для записей после вставки ││
│ └─────────────────────────────────────────────────────────────────────────┘│
│ │
│ db_sync_instance_remove(si) │
│ ├─ снимает подписку на готовность группы │
│ ├─ отменяет таймеры │
│ ├─ освобождает peers[] │
│ └─ сдвигает массив instances[] (таблица БД не удаляется) │
│ │
│ db_sync_destroy(inst) │
│ ├─ etcp_unbind(0x20) │
│ ├─ останавливает все таймеры │
│ ├─ освобождает все инстансы + done_cbks │
│ ├─ закрывает SQLite │
│ └─ u_free(db) │
└─────────────────────────────────────────────────────────────────────────────┘
```
---
## 3. Протокол (форматы кодограмм)
### 3.1 Общий заголовок ETCP
Каждое сообщение db_sync инкапсулируется в ETCP-дейтаграмму:
```
┌─────────────────┬──────────────┬──────────────────────────────┐
│ svc_id (1 byte) │ group_id (8) │ payload (variable) │
│ ETCP_RT_ID=0x20 │ big-endian │ зависит от type │
└─────────────────┴──────────────┴──────────────────────────────┘
```
**group_id** — идентификатор группы в big-endian. Приёмная сторона находит
зарегистрированную таблицу этой группы; входящий пакет не создаёт таблицы или callbacks.
### 3.2 Типы сообщений
| #define | Value | Направление | Назначение |
|---------|-------|-------------|------------|
| `DB_MSG_INIT_SYNC` | 0x01 | Master→Slave | Инициирует синхронизацию |
| `DB_MSG_INIT_RESP` | 0x02 | Slave→Master | Ответ с хешами на точке расхождения |
| `DB_MSG_SEND_DATA` | 0x04 | ↔ | Передача батча записей |
| `DB_MSG_PUSH` | 0x05 | →Peers | Рассылка новой записи всем synced-пирам |
| `DB_MSG_ACK_PUSH` | 0x06 | →Author | Подтверждение получения PUSH |
| `DB_MSG_SYNC_DONE` | 0x07 | ↔ | Завершение синхронизации |
| `DB_MSG_ERROR` | 0x08 | ↔ | Ошибка (NOT_FOUND, DISABLED, NOT_READY) |
| `DB_MSG_REQUEST_SYNC` | 0x09 | Slave→Master | Запрос на инициацию синхронизации |
### 3.3 DB_MSG_INIT_SYNC (0x01)
**Отправитель:** мастер (node_id > peer_id)
**Назначение:** сообщить пиру своё количество записей для поиска точки расхождения
```
┌──────┬──────────────┐
│ 0x01 │ count (4 BE) │
└──────┴──────────────┘
total: 5 bytes
```
- `count` — количество записей в таблице мастера (uint32, big-endian)
### 3.4 DB_MSG_INIT_RESP (0x02)
**Отправитель:** слейв (node_id < peer_id)
**Назначение:** вернуть хеш на точке пересечения tp и разреженные хеши для бинарного поиска расхождения
```
┌──────┬──────────────┬──────────────┬─────────────┬─────────────┬──────────────────────────────────┐
│ 0x02 │ tp (4 BE) │ ch8_at_tp(8) │ has_tail(1) │ sparse_cnt │ sparse_entries[sparse_cnt] │
│ │ │ │ │ (1) │ { pos(4BE), ch8(8) } × sparse_cnt│
└──────┴──────────────┴──────────────┴─────────────┴─────────────┴──────────────────────────────────┘
total: 14 + 12×sparse_cnt bytes (max 206)
```
- `tp` — точка пересечения: `min(peer_count, my_count) - 1` (uint32, network byte order)
- `ch8_at_tp` — первые 8 байт chain_hash на позиции tp (uint64, host byte order)
- `has_tail` — 1 если у слейва есть записи после tp (uint8)
- `sparse_cnt` — количество разреженных точек (uint8)
- `sparse_entries` — массив `{pos:4, ch8:8}` от tp назад с интервалами: 1, 1, 1, 2, 2, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4 (макс. 16)
**Пример sparse-интервалов при tp=10:**
```
tp=10 → entry[0]: pos=9 (tp - 1)
entry[1]: pos=8 (tp - 2)
entry[2]: pos=7 (tp - 3)
entry[3]: pos=5 (tp - 5)
entry[4]: pos=3 (tp - 7)
entry[5]: pos=0 (tp - 11) — только если tp достаточно велико
```
### 3.5 DB_MSG_SEND_DATA (0x04)
**Отправитель:** любой узел
**Назначение:** передать батч записей (до 32) и/или запросить встречные данные
```
┌──────┬───────────┬───────────┬───────────┬──────────────┬──────────────────────────┐
│ 0x04 │ from(4BE) │ count(2) │ vp(4BE) │ want_from(4) │ records[count] │
│ │ │ │ │ │ ≤32 штук │
└──────┴───────────┴───────────┴───────────┴──────────────┴──────────────────────────┘
header: 14 bytes + records
```
- `from` — начальная позиция записей (uint32, network byte order)
- `count` — количество записей в этом батче (uint16, network byte order), ≤ 32
- `vp` — verified position (uint32, BE): последняя позиция, где хеши совпали. `0xFFFFFFFF` если нет
- `want_from` — запрос встречных данных от пира начиная с этой позиции. `0xFFFFFFFF` = `DB_WANT_FROM_NONE` (не запрашивать)
- `records` — массив записей
#### Формат одной записи (record):
```
┌───────┬───────┬──────────┬──────────┬────────────┬──────────┬──────────┐
│ id(8) │ ts(8) │ author(8)│ dlen(4BE)│ data(dlen) │ sig_len │ sig(64) │
│ │ │ │ │ │ =64 (1) │ │
└───────┴───────┴──────────┴──────────┴────────────┴──────────┴──────────┘
минимальный размер: 28 + 1 + 64 = 93 bytes (при dlen=0)
```
- `id` — идентификатор записи (uint64, host byte order)
- `ts` — timestamp в мс (uint64, host byte order)
- `author` — node_id автора (uint64, host byte order)
- `dlen` — длина данных от1 до4000 байт (uint32, host byte order)
- `data` — JSON-данные (dlen байт)
- `sig_len` — длина подписи, всегда 64 (uint8)
- `sig` — Ed25519-подпись: `Ed25519(ts[8] || data[dlen])`, 64 байта
**Note:** поля `id`, `ts`, `author`, `dlen` в host byte order (LE на x86).
### 3.6 DB_MSG_PUSH (0x05)
**Отправитель:** автор записи → все synced-пиры
**Назначение:** рассылка новой записи в реальном времени
Формат идентичен одной record из SEND_DATA:
```
┌──────┬───────┬───────┬──────────┬──────────┬────────────┬──────────┬──────────┐
│ 0x05 │ id(8) │ ts(8) │ author(8)│ dlen(4) │ data(dlen) │ sig_len │ sig(64) │
│ │ │ │ │ │ │ =64 (1) │ │
└──────┴───────┴───────┴──────────┴──────────┴────────────┴──────────┴──────────┘
минимальный размер: 1 + 28 + 1 + 1 + 64 = 95 bytes (при dlen=1)
```
### 3.7 DB_MSG_ACK_PUSH (0x06)
**Отправитель:** получатель PUSH → переславшему пиру
**Назначение:** подтверждение получения PUSH-записи
```
┌──────┬──────────────────┐
│ 0x06 │ message_id (32) │
└──────┴──────────────────┘
total: 33 bytes
```
- `message_id` — SHA256 идентичности записи (group_id, timestamp, подпись; см. chat_feedback.h).
Получатель ACK одним UPDATE находит ровно эту запись, добавляет peer_id в delivery_chain
и увеличивает delivered_peers только при первом ACK от этого пира. Для локального автора
также устанавливается DB_REC_FLAG_WAS_SENT. Постановка PUSH в очередь доставкой не считается.
ACK отправляется и при корректном дубликате; записи с одинаковыми автором/временем независимы.
### 3.8 DB_MSG_SYNC_DONE (0x07)
**Отправитель:** любой узел
**Назначение:** финальная сверка — сравнение количества записей и последнего chain_hash
```
┌──────┬─────────────┬──────────────────┐
│ 0x07 │ count (4BE) │ chain_hash8 (8) │
└──────┴─────────────┴──────────────────┘
total: 13 bytes
```
- `count` — количество записей в таблице отправителя (uint32, network byte order)
- `chain_hash8` — первые 8 байт последнего chain_hash (uint64, host byte order)
**Обработка:**
- `count == my_count && ch8 == my_ch8` → sync завершён, ss→2, fire `db_sync_done_cb`
- `my_count < peer_count` → запрашиваем хвост (SEND_DATA from=my_count, want=my_count)
- `my_count > peer_count` → отправляем хвост (SEND_DATA from=peer_count)
- `count == my_count && ch8 != my_ch8` → принимаем как сошедшееся (конвергенция)
### 3.9 DB_MSG_ERROR (0x08)
**Отправитель:** любой узел
**Назначение:** сигнализировать об ошибке (инстанс не найден или выключен)
```
┌──────┬──────────┐
│ 0x08 │ code (1) │
└──────┴──────────┘
total: 2 bytes
```
Коды ошибок:
| #define | Value | Значение |
|---------|-------|----------|
| `DB_ERR_NOT_FOUND` | 0x01 | Таблица этой группы не зарегистрирована |
| `DB_ERR_DISABLED` | 0x02 | Инстанс существует, но disabled |
| `DB_ERR_NOT_READY` | 0x03 | Участники или групповая сессия ещё не готовы |
При получении ERROR сбрасываются `sync_state` и ожидание ответа; планируется повтор.
Ошибка отправки также возвращает обмен в IDLE. Обе роли ждут прогресс не более 15 секунд;
таймер повторов работает с периодом 5 секунд, пока есть несинхронизированные READY-пиры.
Появление нового READY запускает обмен независимо от того, работал ли таймер.
При JOIN запись connection-узла из JOIN_INFO_RESP применяется через `member_sync_apply_record()`:
подписи проверяются, модель и Merkle-индекс обновляются в одной транзакции. JOIN_READY
подтверждает вступление, но история разблокируется только после успешного member_sync.
### 3.10 DB_MSG_REQUEST_SYNC (0x09)
**Отправитель:** слейв → мастер
**Назначение:** запросить инициацию синхронизации (слейв говорит мастеру «начни sync»)
```
┌──────┐
│ 0x09 │
└──────┘
total: 1 byte (без payload)
```
При получении REQUEST_SYNC мастер вызывает `initiate_sync()` → шлёт `INIT_SYNC`.
---
## 4. Алгоритм синхронизации (по шагам)
### 4.1 Запуск
1. При conn UP для каждого enabled инстанса: `si_peer_add()` → `initiate_sync()`
2. `initiate_sync()`:
- Если `node_id > peer_id` (мастер): шлёт `INIT_SYNC` с `my_count`
- Если `node_id < peer_id` (слейв): шлёт `REQUEST_SYNC`
### 4.2 Обработка INIT_SYNC (слейв)
1. `db_sync_flush_recalc()` — синхронно завершает все отложенные пересчёты
2. Вычисляет `tp = min(peer_count, my_count) - 1` (точка пересечения)
3. Вычисляет `ch8_at_tp` — chain_hash8 на позиции tp
4. Определяет `has_tail = (my_count > tp + 1)`
5. Строит разреженные хеши: от tp назад с интервалами 1,1,1,2,2,4×8
6. Отправляет `INIT_RESP` с `tp`, `ch8_at_tp`, `has_tail`, `sparse_hashes`
7. Устанавливает `sync_state = 1` для этого пира
### 4.3 Обработка INIT_RESP (мастер)
1. `db_sync_flush_recalc()` — синхронно завершает все отложенные пересчёты
2. Извлекает `tp`, `peer_ch8`, `has_tail`, `sc`, `sparse_hashes`
3. Вычисляет `my_ch8_at_tp`
**Ветки:**
- **peer_ch8 == 0 && sc == 0** (пир пуст): шлёт **все** свои данные батчами по 32 записи. Последний батч с `want_from = my_count` (запрос встречных данных, которые будут пустыми → SYNC_DONE)
- **my_ch8 == peer_ch8** (совпадение на tp):
- `!has_tail && my_count <= tp+1`: обе стороны идентичны → сразу `SYNC_DONE`
- `has_tail`: отправляем свои записи после tp и запрашиваем записи пира (`want_from = tp+1`)
- **my_ch8 != peer_ch8** (расхождение): ищем первую различающуюся позицию `fm` через sparse-хеши. `vp = fm-1`. Отправляем данные с fm и запрашиваем данные пира с fm.
### 4.4 Обработка SEND_DATA
1. `db_sync_flush_recalc()` — синхронно завершает все отложенные пересчёты
2. Парсит `from`, `count`, `vp`, `want_from`
3. Для каждой записи (макс. 32): `si_parse_record()` → `db_record_insert()`
- Проверка длины тела и подписи до чтения64 байт; проверка подписи конкретных входящих байтов
- Если строка уже есть, независимо проверяются сохранённые байты: только доказанная порча допускает DELETE
- Проверка дубликата по `(timestamp, author_signature)`
- Вычисление chain_hash
- Вставка в SQLite, запуск async recalc
- Вызов `on_insert` коллбэка
4. Корректирует `synced_pos` (и соседних пиров, если запись вставлена не в конец)
5. Если `want_from != DB_WANT_FROM_NONE`: отправляет встречный SEND_DATA
6. Если `want_from == DB_WANT_FROM_NONE` и `sync_state == 1`: отправляет `SYNC_DONE`
Ошибка формата, подписи, ключа, SQL или пересчёта прерывает раунд без подтверждения его позиций.
Уже успешно вставленные записи остаются в БД; повторный раунд принимает их как дубликаты.
При выдаче пачки сохранённая порча удаляется после завершения SELECT в своей транзакции;
пачка старого раунда не отправляется. После COMMIT сбрасываются позиции всех пиров и пересчёт начинается с0.
### 4.5 Обработка SYNC_DONE
1. `db_sync_flush_recalc()`
2. Сравнивает `my_count`, `my_ch8_last` с `peer_count`, `peer_ch8_last`
3. **Совпало** → sync_state=2, fire `db_sync_done_cb`
4. **my < peer** → запрашиваем хвост SEND_DATA(from=my_count, want=my_count)
5. **my > peer** → отправляем хвост SEND_DATA(from=peer_count)
6. **count совпало но хеши разные** → принимаем как converged
### 4.6 PUSH (реальное время)
1. При локальной вставке (`db_sync_insert_signed`):
- Формирует PUSH-сообщение
- Рассылает всем пирам с `sync_state >= 1`
2. При получении PUSH:
- Вставляет запись через `db_record_insert()`
- После успешной вставки/корректного повтора шлёт `ACK_PUSH(message_id)` переславшему пиру
- Если запись вставлена в середину (не в конец) — сбрасывает `synced_pos` всех пиров до позиции вставки-1
3. При получении ACK_PUSH:
- Для локального автора помечает запись флагом `WAS_SENT`
- Идемпотентно обновляет `delivery_chain`/`delivered_peers` по message_id
---
## 5. Безопасность
1. **Ed25519-подписи:** каждая запись подписана `Ed25519(timestamp || json_data)`. Проверяется при локальной вставке и при приёме от пира.
Требуемый контракт удаления: завершённая проверка установила неверность подписи самих сохранённых байтов
либо доказан повреждённый формат этой записи. Отсутствие ключа, отказ OpenSSL/памяти/SQL не доказывают порчу.
Неверная входящая копия не доказывает неверность корректной строки с тем же ключом.
db_verify_author_sig различает VALID/BAD_SIGNATURE/BAD_FORMAT/DEFERRED/ERROR.
Операционные ошибки, включая SQLITE_NOMEM при чтении BLOB, сохраняют строки.
DELETE и замена повреждённой строки атомарны; сброс позиций и планирование цепочки выполняются после COMMIT.
2. **Цепной хеш:** SHA256-цепочка защищает от вставки/удаления записей в середине истории. При проверке (`db_sync_chain_verify`) или при обнаружении расхождения во время отправки — запускается пересчёт с позиции ошибки.
3. **Публичные ключи:** Ed25519 публичный ключ пира получается из трёх источников (по приоритету):
- Локальный `inst->my_ed25519_pubkey` (для своих записей)
- `topo_node_sqlite_get_ed25519_pubkey()` (из БД узлов)
- `conn->peer_ed25519_pubkey` (из ETCP handshake)
4. **Удаление:** кроме TTL неподтверждённых, удаляются доказанно повреждённые сохранённые строки.
Полный аудит TTL, финального сравнения хешей и callbacks ещё не завершён.
---
## 6. Конфигурация
```ini
[global]
db_sync_enabled = 1 # включить модуль (по умолчанию 0)
db_sync_ttl = 86400 # TTL неподтверждённых записей в секундах (по умолчанию 86400)
db_path = /var/lib/utun # путь к БД (по умолчанию /tmp/utun_db_sync)
```
# db_sync — подписанная история и MESSAGES Merkle
История хранится в общей SQLite ядра. `DB_SYNC_INSTANCE` связывает одну таблицу
с CHAT-группой; namespace — десятичный group_id. Полный обмен выполняет общий
`merkle_sync`, dataset `MT_DATA_MESSAGES=2`. На `ETCP_ID_DB_SYNC` остаются PUSH
и адресный ACK_PUSH. Старый обмен цепочками, sparse-поиск и сетевой OFFSET удалены.
Все вызовы и callbacks выполняются в uasync-потоке экземпляра.
## Идентичность и корень
Подпись Ed25519 покрывает `timestamp[8 в порядке хоста] || точные байты data`.
Тело имеет длину 1–4000 байт, подпись — 64 байта. Формат подписанного тела сохранён.
`message_id = SHA256("utun-chat-message-v1" || BE64(group_id) || BE64(timestamp) || signature)`.
Порядок набора — лексикографический по полному message_id[32].
Лист хеширует канонические записи в этом порядке. В запись входят message_id,
timestamp, author node_id, длина и точные байты тела, подпись. Дерево и границы
листьев описаны в [merkle_sync_doc.md](merkle_sync_doc.md).
Локальные id, flags, local_attrs, delivered_peers и delivery_chain в корень не входят.
Поэтому одинаковая подписанная история даёт одинаковый корень при разных локальных id.
Два сообщения одного автора в одной миллисекунде различаются подписью и message_id.
Допуск пересылающего пира проверяется отдельно: требуется READY группы и членство,
привязанное к X25519-ключу соединения. Статус deleted исходного автора не фильтрует
его корректно подписанную историю, корень и выдачу. Исключённому участнику закрыт вход
в группу; разрешённые участники продолжают обмен историей всех авторов.
Политика отображения реакций/голосов относится к FEEDBACK, а не MESSAGES.
## Проверка и безопасное удаление
Сохранённая запись различает пять результатов: VALID, BAD_FORMAT, BAD_SIGNATURE,
DEFERRED (ключ ещё неизвестен), ERROR (операционная ошибка).
Удаляется только строка, чьи собственные байты имеют недопустимый формат либо
чья подпись не совпала при успешно выполненной криптографической проверке.
SQL, NOMEM, ошибки OpenSSL и неизвестный ключ сохраняют данные и откладывают обмен.
Ошибка никогда не превращается в пустой лист или успешное подтверждение.
Неверная входящая копия не доказывает повреждение сохранённой.
При совпадении ключа дубликата проверяются собственные сохранённые байты.
Корректная копия сохраняется; доказанно повреждённая удаляется.
Отказ удаления, индекса или COMMIT откатывает всю транзакцию.
Ключ автора ищется в локальной идентичности, проверенном node_registry, общей
таблице nodes и текущем соединении. Ожидание ключа не мешает запуску чата,
но блокирует публикацию корня соответствующей истории.
Поступление MEMBERS, включая другой канал, ускоряет повтор ожидания общего ключа.
## Подготовка индекса и короткие транзакции
После регистрации таблицы её индекс pending, сетевой обмен закрыт.
После создания общего движка `db_sync_register_model` подключает MESSAGES и запускает:
1. Проверку до 16 сохранённых строк по rowid за один шаг. Формат/подписи читаются
и проверяются до write-транзакции. Внутри короткой транзакции строки перечитываются
и сверяются с проверенными байтами; доказанная порча удаляется, message_id/bucket
восстанавливаются. Курсор сдвигается только после COMMIT.
2. Пересчёт до 16 непустых листьев и их путей за транзакцию.
3. Публикацию готовности корня и запуск разрешённых READY-пиров.
Между шагами таймер на 1 tick (0,1 мс) возвращает управление сетевому циклу.
При ошибке сохраняются pending и последний подтверждённый курсор; повтор через 5 с.
Изменение данных во время подготовки требует нового обхода.
`db_sync_verify` — явно синхронная диагностическая перепроверка; рабочая синхронизация
использует таймеры. `db_sync_read_root` возвращает 1, пока подготовка не закончена.
Обычная вставка и изменение её Merkle-пути фиксируются одной короткой транзакцией.
Входящая страница содержит максимум 16 записей и ограничена размером пакета;
её формат, ключи, порядок, курсоры и подписи проверяются до BEGIN IMMEDIATE.
Данные страницы и индекс затем фиксируются вместе. Сеть и ожидание ответа находятся
вне транзакций. Одна транзакция на всю историю не создаётся.
SQLite допускает одного writer: другие записи ждут короткий commit; при WAL читатели
могут продолжать чтение. Ограничение по числу строк/листьев не является гарантией
времени в миллисекундах: оно зависит также от диска и наполнения листа.
Пересчёт родителя/листа не выполняет повторную проверку Ed25519 под write-lock.
Существующий отличающийся дубликат требует независимой проверки сохранённой копии.
## Обмен и критерий завершения
Общий wire v4 использует свежие round_id и accept_id обеих сторон.
Два прохода находят различия по полным хешам и передают страницы листьев.
Курсор — полный message_id; продолжение выбирает key > after, без OFFSET.
Итоговые CHECK/DONE сверяют полный корень[32] и локальную ревизию.
Успех означает подтверждённую контрольную точку конкретного раунда.
Совпадение числа строк само по себе не завершает обмен.
Вставка перед уже пройденным курсором может потребовать следующего раунда.
Изменения дерева отмечаются после COMMIT и автоматически запускают повтор.
При прекращении изменений, доступных ключах и исправном транспорте обмен объединяет
неизменяемые допустимые записи. Постоянный конфликт/ошибка возвращается как ошибка,
без ложного done. TTL не создаёт распределённого tombstone: другая копия может вернуть
ранее удалённую локально запись.
## PUSH и доставка
Заголовок: `service:1 | group_id:8BE | type:1`.
PUSH (0x05) содержит одну каноническую запись:
`message_id:32 | timestamp:8BE | author:8BE | data_len:4BE | data | signature:64`.
ACK_PUSH (0x06) содержит ровно message_id[32]. После успешной вставки либо допустимого
повтора получатель отправляет ACK. Постановка PUSH в очередь не считается доставкой.
Один идемпотентный UPDATE учитывает уникальный peer_id в delivery_chain и увеличивает
delivered_peers только один раз. WAS_SENT выставляется для локально созданной записи.
Bulk-сходимость не подменяет адресное подтверждение конкретного PUSH.
PUSH ускоряет распространение; при отказе очереди committed изменение остаётся dirty
и будет найдено MESSAGES. Неверная запись не пересылается.
## Политика запуска и жизненный цикл
Таблица начинает с gated=1; чат открывает историю после успешного MEMBERS.
Нужны готовый проверенный индекс, READY группы и допуск транспортного пира.
Для истории sound_on разрешает обмен со спящим пиром; sound_off откладывает его.
MEMBERS и FEEDBACK по умолчанию откладываются при peer_sleep_phase.
Смена sleep/sound/членства переоценивает сессии; возврат допуска возобновляет историю
на прежнем READY-соединении. Пауза диагностического пира блокирует весь обмен истории.
На Android периодический retry ждёт активной фазы standby; ожидание отменяется при destroy.
`on_result` модели получает результат и автоматических раундов.
Глобальный done вызывается только для MT_OK. Самоудаление подписки, удаление следующей
подписки либо канала во время callback безопасны.
Для bulk уведомления on_insert отложены до выхода из обработчика Merkle и выполняются
после COMMIT. При rollback они отменяются. Callback видит зафиксированную страницу.
Для локальной вставки/PUSH уведомление выполняется после commit в текущем обработчике.
Удаление экземпляра отменяет только MESSAGES соответствующего namespace,
таймеры и отложенные уведомления; исходная таблица остаётся.
destroy освобождает модуль, но не общую SQLite. Уничтожение всего ядра из callback
должно быть отложено согласно общему контракту сервисов.
## Схема и TTL
Таблица содержит timestamp, node_id, id, flags, data, author_signature, message_id,
bucket, local_attrs, delivered_peers, delivery_chain.
PRIMARY KEY — (timestamp, author_signature), message_id UNIQUE, bucket — старшие 25 бит ключа.
chain_hash удалён. При перестройке схемы подписанные и локальные поля сохраняются;
производные поля вычисляются заново. DDL/copy/rename/COMMIT атомарны.
Записи с доказанно неверной длиной подписи удаляются с диагностикой; подписи корректной
длины проверяет последующий preflight.
TTL сравнивает миллисекунды Unix и удаляет только старые собственные записи без WAS_SENT.
За транзакцию удаляется максимум 16 строк. В том же commit очищается производный
MESSAGES-индекс канала; до завершения повторной проверки корень остаётся pending.
Полная пачка продолжается через 1 tick, следующая плановая проверка — через час.
Это локальная политика хранения, а не удаление чужой истории или отмена сообщения.
## Диагностика и проверки
Категория chat_sync показывает регистрацию, preflight/удаления/готовность, commit страниц,
TTL, ACK и причины отказов. Общий автомат/TX/RX/контрольные точки — member_sync.
DEBUG содержит namespace, dataset, peer, round, размеры и состояние;
несовпавшие handshake-токены диагностируются без изменения прогресса.
test_db_sync проверяет реальный транспорт 2/3 узлов, PUSH/ACK и длинную историю с равным
числом строк, отличием в начале и разными локальными id.
test_db_sync_integrity проверяет реальные подписи, SQL/crypto/NOMEM/COMMIT/timer faults,
атомарность страниц, курсоры, поздние ключи, исключённого автора, TTL и callbacks.
test_db_sync_schema проверяет сохранность полей и rollback миграции.
test_dm_invite проверяет настоящий JOIN и восстановление истории после таймаута.
Индекс предполагает запись содержимого через модель. Произвольное внешнее изменение
SQLite не выявляется непрерывно: оно обнаруживается при перепроверке/чтении соответствующих
данных. Это не заменяет отдельную проверку целостности файла SQLite.
+20 -5
View File
@@ -1,4 +1,4 @@
# merkle_sync — протокол 3
# merkle_sync — протокол 4
Аудит 26.10.2026 частичный: движок и адаптер участников прочитаны полностью,
контракты ниже уточнены; дефекты очистки member_sync и запуска feedback исправлены.
@@ -12,17 +12,18 @@
- merkle_sync: обмен, backpressure и жизненный цикл сессий.
- member_sync: формат участников, подписи, версии, применение страниц.
- chat_feedback: подписанные состояния реакций/ответов, ver, отмены и применение страниц.
- db_sync: подписанная история MESSAGES, первоначальная проверка SQLite, страницы и PUSH/ACK.
Движок не знает CHAT-групп. Namespace — каноническая десятичная строка uint64_t.
Все операции выполняются в одном потоке uasync экземпляра.
Сессия определяется тройкой (peer, dataset, namespace). MEMBERS=0, FEEDBACK=1.
Сессия определяется тройкой (peer, dataset, namespace). MEMBERS=0, FEEDBACK=1, MESSAGES=2.
В индексе SQLite namespace хранится как `dataset:channel_id`; корни наборов независимы.
## Дерево и транзакции
Корень имеет уровень 0. Уровни 1–5 разбивают старшие 25 бит ключа на группы по 5 бит.
Полный ключ курсора — 32 байта с лексикографическим порядком; ветвь задаётся первыми 8 байтами.
Для участника ключ — BE64(node_id) и 24 нулевых байта. Для feedback — SHA256 идентичности записи.
Для участника ключ — BE64(node_id) и 24 нулевых байта. Для feedback — SHA256 идентичности записи, для истории — message_id.
Совпадение первых 64 бит не объединяет записи и не пропускает их при пагинации.
SHA256 листа: `0x4d || level:1 || item_hashes`, записи упорядочены по unsigned key.
SHA256 родителя: `0x4d || level:1 || child_hash[0] || ... || child_hash[31]`.
@@ -37,11 +38,19 @@ SHA256 родителя: `0x4d || level:1 || child_hash[0] || ... || child_hash[
## API
init принимает service и data_ops начальной модели MEMBERS. register добавляет другие наборы.
Каждая модель задаёт update_bucket_hash, get_page, apply_items,
необязательные validate_peer и finish.
Каждая модель задаёт update_bucket_hash, get_page и способ применения страницы,
необязательные validate_peer, can_run, on_result и finish.
update_bucket_hash добавляет хеши одного листа в переданный SHA256-контекст.
get_page получает ёмкость буфера и курсор; возвращает размер, последний ключ и флаг продолжения.
apply_items проверяет весь формат и применяет страницу вместе с индексом атомарно.
Вместо apply_items можно задать apply_page: он также получает prefix, after, next и more
и проверяет принадлежность фактических записей запрошенному листу/диапазону.
validate_peer разрешает обмен только при возврате 1; проверяется и перед отправкой.
can_run задаёт политику работы модели; без него peer_sleep_phase откладывает обмен.
История разрешает sound_on для спящего пира. recheck_peer завершает запрещённые
попытки, освобождая пакет/waiter/таймер и сохраняя подписку для последующего start/changed.
on_result получает результаты всех раундов, включая автоматические, до пользовательских callbacks;
он может отменить сессию. Указатели и список пользовательских callbacks захватываются заранее.
finish проверяет отложенные зависимости модели перед подтверждением корня.
Вместо непосредственного apply_items модель может задать все четыре callback:
@@ -58,6 +67,7 @@ MT_OK означает совпадение корней конкретного
cancel отменяет только заданный dataset/peer/namespace.
cancel_ns отменяет все datasets канала, cancel_peer — все datasets пира.
cancel_dataset_ns/cancel_dataset_peer ограничивают отмену выбранным набором.
Все cancel и destroy освобождают запросы без callback.
DOWN/DELETE/REINIT завершают запросы ошибкой и освобождают сессию.
После восстановления соединения интеграционный слой повторяет start.
@@ -102,6 +112,8 @@ WAKE также перезапускает незавершённый раунд
Все дальнейшие пакеты проверяют оба идентификатора, номер запроса и фазу. После cancel/start старый BEGIN может получить
новый challenge, но прежние PAGE/DONE/ERROR не соответствуют новому accept_id. Дубликат BEGIN не меняет текущий challenge.
WAKE содержит нулевые идентификаторы, BEGIN — нулевой accept_id. Старый wire не поддерживается.
Ошибка генератора случайных байтов завершает запрос MT_ERR_IO до установки нового раунда.
Дубликат READY с тем же accept_id не перезапускает текущий обход.
Все целые big-endian. Максимальный пакет вместе с заголовком — 16384 байта.
Типы 0x10–0x19: WAKE, BEGIN, READY, QUERY, HASHES, PAGE, TURN, CHECK, DONE, ERROR.
@@ -134,6 +146,9 @@ test_merkle_sync проверяет индекс, односторонний sta
test_merkle_protocol проверяет ошибочные пакеты/курсоры, сохранение соседних ветвей,
отмену waiter, ошибки apply/SQLite, backpressure, disconnect и повторное подключение,
изоляцию datasets и пагинацию разных ключей с одинаковым 64-битным префиксом.
Дополнительные регрессии: cancel/start и старые PAGE/DONE/ERROR, дубликаты BEGIN/READY,
отказ RNG обеих сторон, политика спящего пира и восстановление допуска на прежнем соединении.
История и её ограниченные транзакции описаны в [db_sync_arch.md](db_sync_arch.md).
test_chat_feedback проверяет подписи, монотонный ver, отмены, старые записи,
конфликты одного ver, атомарность страниц, восстановление дерева и удаление канала.