diff --git a/src/chat/db_sync_arch.md b/src/chat/db_sync_arch.md index 1b8e13aa..0ee8d2e1 100644 --- a/src/chat/db_sync_arch.md +++ b/src/chat/db_sync_arch.md @@ -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_` с совпадающим хешем | -| **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 -│ │ │ mcpc → 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. diff --git a/src/chat/merkle_sync_doc.md b/src/chat/merkle_sync_doc.md index 394e3f10..f0509015 100644 --- a/src/chat/merkle_sync_doc.md +++ b/src/chat/merkle_sync_doc.md @@ -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, атомарность страниц, восстановление дерева и удаление канала.