Files
utun2/src/chat/merkle_sync_doc.md
T

23 KiB
Raw Blame History

merkle_sync — протокол 4

Сверка merkle_sync.h и этого документа с реализацией завершена 26.10.2026. Проверены общий движок, адаптер MEMBERS, зависимые состояния FEEDBACK и перенос истории MESSAGES; найденные дефекты исправлены. Остальные модули чата проверяются отдельно. Объём, результаты и ограничения: отчёт, разделы 6, 9 и 13.

Разделение ответственности

  • merkle_tree: производный индекс SQLite, хеши листьев и родителей.
  • 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, MESSAGES=2. В индексе SQLite namespace хранится как dataset:channel_id; корни наборов независимы.

Дерево и транзакции

Корень имеет уровень 0. Уровни 1–5 разбивают старшие 25 бит ключа на группы по 5 бит. Полный ключ курсора — 32 байта с лексикографическим порядком; ветвь задаётся первыми 8 байтами. Для участника ключ — 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]. Хеш пустого поддерева — 32 нулевых байта; строка индекса для него отсутствует. Пересчитывается один лист и его предки, до первого неизменившегося хеша.

Индекс создаётся заново при init и заполняется моделью из исходных записей. Модель меняет данные и вызывает recompute_path в одной транзакции, затем changed после commit. Без внешней транзакции recompute_path создаёт savepoint и уведомляет самостоятельно. Ошибки не заменяются пустыми страницами или нулевыми хешами.

API

init принимает service и data_ops начальной модели MEMBERS. register добавляет другие наборы. Каждая модель задаёт 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: begin_pull, stage_page, commit_pull, abort_pull. begin_pull создаёт ненулевой контекст; stage_page получает также prefix и оба курсора страницы и накапливает данные. commit_pull публикует результат после завершения своего прохода, до TURN/CHECK. abort_pull освобождает контекст как после commit, так и при restart/cancel/ошибке. В этом режиме apply_items необязателен; атомарность публикации обеспечивает модель.

start подключает (dataset, namespace) к текущему соединению и запрашивает контрольную точку. Повторные start объединяются, обход не сбрасывается, каждый принятый callback вызывается один раз. После подключения изменения синхронизируются автоматически. Broadcast/send-back не требуются. 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. Callback может отменить сессию; уничтожение всего экземпляра следует отложить через call_soon.

Обмен

Меньший node_id ведёт раунд; второй запрашивает его через WAKE. WAKE также перезапускает незавершённый раунд после cancel/start второго узла.

  1. BEGIN(new round) → READY.
  2. Ведущий запрашивает хеши детей и обходит различающиеся ветви в глубину.
  3. Лист запрашивается страницами с возрастающим курсором.
  4. TURN меняет направление: второй узел выполняет такой же pull.
  5. Второй проверяет finish и отправляет CHECK(root).
  6. Ведущий сверяет корень, проверяет finish и отправляет DONE(root).
  7. Второй сверяет DONE со своим текущим корнем.

Ведущий сообщает MT_OK после передачи DONE в надёжный транспорт, если локальная ревизия не изменилась после проверки. Второй сообщает MT_OK после проверки полученного DONE. Изменения во время обмена вызывают следующий раунд. Два завершённых раунда с одной и той же несовпадающей парой корней дают MT_ERR_CONFLICT. Детерминированное слияние обеспечивает модель; движок не выбирает победителя конфликта.

Ожидается только один ответ QUERY. Стек — максимум шесть кадров с сохранёнными соседними ветвями. Номера запросов проверяются, ответы другого раунда игнорируются. Повторную передачу обеспечивает ETCP, отдельных таймеров повторов нет. На исходящий пакет (включая ожидание backpressure) и последующее ожидание ответа взводится таймер 30 секунд; постановка следующего пакета заменяет таймер. Истечение завершает ожидания MT_ERR_TIMEOUT; по одному таймеру на активную сессию. Периодического bg_check и отдельного WAL-таймера в текущем merkle_sync нет.

При отзыве допуска модели revalidate_peers завершает ожидания этого пира MT_ERR_DATA, освобождает подготовленный пакет/таймер/waiter, но сохраняет подписку в FAILED. После восстановления changed запускает новый раунд: ждать ответа на ранее отброшенный пакет не нужно. Вызовы выполняются после commit; done callback может отменить сессию без use-after-free.

Wire

Заголовок v4: service:1 | namespace:8 | type:1 | round:8 | request:4 | dataset:1 | round_id:16 | accept_id:16 (55 байт). round — диагностический счётчик. Свежий round_id создаёт инициатор BEGIN, свежий accept_id добавляет ответчик READY. Все дальнейшие пакеты проверяют оба идентификатора, номер запроса и фазу. После 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.

Тип Payload
WAKE, BEGIN, READY, TURN пустой; request=0; у WAKE также round=0
QUERY level:1, prefix:8, after_valid:1, after:32
HASHES 32 хеша детей по 32 байта в порядке индексов
PAGE more:1, next:32, model_data
CHECK, DONE корень 32 байта; request=0
ERROR положительный код ошибки 4 байта; request=0

Курсор продолжения должен возрастать и принадлежать запрошенному листу. Длины, состояние, тип, префикс и номер запроса проверяются до применения. Формат model_data определяет адаптер. Старый протокол не поддерживается.

Очереди, диагностика, тесты

Сессия удерживает ETCP_CONN и исходную очередь. У неё один подготовленный пакет, один waiter и один отложенный вызов. Передача идёт только после разрешения waiter. Изменения объединяются флагом dirty; неограниченной out-очереди нет. Отмена снимает ожидающий и отложенный callback waiter независимо от links_up.

Категория member_sync: INFO — attach, начало/завершение раунда, detach; DEBUG — TX/RX, номера запросов, размеры, состояние; ERROR — причина отказа. Ведущий выводит счётчики переданных и полученных байтов при подтверждении.

test_merkle_sync проверяет индекс, односторонний start, двустороннее слияние, несколько узлов, параллельные изменения, большой лист с пагинацией и автоматическое обновление. test_merkle_protocol проверяет ошибочные пакеты/курсоры, сохранение соседних ветвей, отмену waiter, ошибки apply/SQLite, backpressure, disconnect и повторное подключение, изоляцию datasets и пагинацию разных ключей с одинаковым 64-битным префиксом. Дополнительные регрессии: cancel/start и старые PAGE/DONE/ERROR, дубликаты BEGIN/READY, отказ RNG обеих сторон, политика спящего пира и восстановление допуска на прежнем соединении. История и её ограниченные транзакции описаны в db_sync_arch.md. test_chat_feedback проверяет подписи, монотонный ver, отмены, старые записи, конфликты одного ver, атомарность страниц, восстановление дерева и удаление канала.

Подписанные состояния feedback

Таблица feedback_ хранит message_id, node_id, kind, ver, data и signature. Идентичность записи — (message_id, node_id, kind); kind=1 — reaction, kind=2 — answer. ver положителен, максимум INT64_MAX. Меньшая версия игнорируется. При одинаковом ver выбирается лексикографически большее data; конфликт логируется. Пустое data — сохраняемая отмена, её нельзя удалять TTL-очисткой. Подпись покрывает domain utun-chat-feedback-v1, BE64(channel_id) и wire-тело без подписи. Wire-запись: message_id:32, node_id:8, kind:1, ver:8, data_len:2, data, signature:64. Страница: count:2 и count записей; все числа big-endian, data максимум 2048 байт. Хеш листа включает канонические тела без signature: разные корректные подписи одного тела не создают расхождения корней. Подписи проверяются перед включением записи в активный набор. Членство проверяется через подписи дерева приглашений и join, привязывающий Ed25519 к узлу. Для init/hash/page используется общий отбор: только author_key=ACTIVE, с повторной проверкой подписанного состояния и его record_key/bucket. INACTIVE/PENDING/INVALID не включаются в набор. SQLite/OpenSSL-ошибки не кодируются пустым листом или отсутствующим автором. Исключённый/отсутствующий автор и ожидающий ключ сохраняются в БД с прежними ver и отменами, не блокируют startup, но не входят в корень, выдачу и результаты. После возврата они проверяются заново. Новые состояния INACTIVE/PENDING не сохраняются; такая запись устаревшей страницы не мешает применению остальных корректных записей. Локальное действие требует ACTIVE даже при no-op.

Изменение удостоверения/deleted/общего ключа атомарно пересчитывает feedback автора и записей, зависящих от его ключа как signed_by, во всех каналах. Отложенный ключ не блокирует пачку MEMBERS; его поступление, включая другой канал, включает ранее сохранённые допустимые состояния. Отдельного сохраняемого флага активности нет: проверка принадлежит member_sync. После внешнего commit обновляются сессии и адресные FEEDBACK_CHANGED; профиль без изменения удостоверения/допуска не требует такого пересчёта и уведомлений. Формат не содержит подписанной границы отзыва: после восстановления применяются обычные ver/data, без утверждения, что подпись была создана до исключения. Слой feedback хранит подписанные байты независимо от наличия исходного сообщения; семантику выбранных пунктов и эмодзи проверяет вызывающий модуль до учёта результата.

Опросы и реакции

chat_poll_codec задаёт общий каталог 16 эмодзи и канонические кодеки. Реакция: count=1 и номер эмодзи; автор выбирает только один смайлик на сообщение. Выбор другого смайлика заменяет предыдущий с новым ver; повторное нажатие отменяет реакцию. Ответ: count:1, отсортированные уникальные номера пунктов, custom_len:2, UTF-8 текст. Пустой набор/ответ кодируется нулевой длиной. Собственный текст — максимум 1024 байта. Структурно неверные состояния не применяются; семантически неверные подписанные ответы сохраняются, но исключаются из подсчёта, чтобы порядок получения опроса не менял Merkle-корень.

Опрос — подписанное сообщение content_type=poll, data=b64: + URL-safe base64. Wire v1: version:1=1, mode:1 (single=1/multiple=2), allow_custom_answer:1, option_count:1, затем вопрос и варианты как length:2 + UTF-8. Числа big-endian, весь poll максимум 2048 байт. Вопрос — максимум 512 байт, 2–32 разных непустых варианта по 256 байт. Порядок вариантов неизменяем; номер варианта — индекс с нуля. single разрешает один вариант либо собственный текст, multiple — варианты и необязательный текст. Изменение ответа заменяет его целиком с ver+1; нулевой ответ отменяет голос.

message_id сохраняется в msg_ с UNIQUE-индексом: SHA256(utun-chat-message-v2 || BE64(channel_id) || BE64(timestamp) || BE64(author) || nonce[16]). ID не меняется при правке текста; MESSAGES заменяет запись только при большем ver. Конверт и подпись версии описаны в db_sync_arch.md. Этот ID одинаков на всех узлах и возвращается API сообщений и результатом публикации опроса. Получение исходного сообщения повторно проверяет отложенные ответы. Пользовательские результаты вычисляются по последним допустимым непустым состояниям; проценты — от числа ответивших. При нескольких вариантах сумма процентов может превышать 100%.

Команды poll_create, vote, react, feedback, reaction_catalog разбираются единым chat_feedback_command в потоке ядра. Desktop и Android получают адресные события FEEDBACK_CHANGED и коррелированные результаты FEEDBACK_RESULT; GUI-поток не пишет подписанные данные. При старте чата запускается member_sync по известным онлайн-пирам, затем feedback sync: это нужно и при restart поверх сохранившегося UP-транспорта, когда нового conn_up не будет.

test_chat_poll проверяет пользовательский API, кодеки, ограничения, отмены и отложенный опрос. test_chat_poll_network проверяет A ↔ B ↔ C, Unicode-ответ, реакцию, остановку/запуск C, исключение A, позднее подключение D без неактивной истории и возврат A. После возврата D получает сохранённые состояния, A передаёт новую отмену через прежний UP-транспорт; результаты и корни активного набора сходятся. test_chat_feedback проверяет также purge, межканальную зависимость ключа, отсутствие уведомлений от профиля и rollback SQL/index/commit.