Детерминированный ребейз хеш‑цепочки после сплит‑брейна в P2P‑сети

Gorgona - децентрализованная P2P‑сеть для доставки шифрованных сообщений. Узлы синхронизируются через меш, порядок записей задаётся Snowflake ID, целостность истории держится на хеш‑цепочке (XXH3). Центрального сервера нет.

Ниже описан механизм, который восстанавливает расходящиеся цепочки после сетевой изоляции узла без координации, без Raft и без блокчейн‑консенсуса, благодаря консенсусу без кворума, с детерминированным прошлым, нулевым знанием нод (Blind E2E) и встроенным физическим временем (Time-Lock + Snowflake) на микроскопическом объеме памяти.

Модель данных

Каждый алерт содержит:

  • id - Snowflake ID, монотонно растущий, задаёт тотальный порядок;

  • prev_hash - хеш предыдущего алерта в цепочке;

  • curr_hash - хеш текущего алерта, вычисляется от бинарных зашифрованных данных алерта,id, prev_hash и content_hash.

Инвариант: при одинаковом наборе алертов все узлы приходят к одинаковому curr_hash на вершине цепочки. Сравнение вершин - точка сверки при подключении пиров.

Сценарий изоляции

Изоляция узла через iptables с сохранением SSH‑доступа для наблюдения за логами:

iptables -A INPUT  -s 192.168.1.10 -j ACCEPT && \
iptables -A OUTPUT -d 192.168.1.10 -j ACCEPT && \
iptables -A INPUT  -p tcp --dport 22 -j ACCEPT && \
iptables -A OUTPUT -p tcp --sport 22 -j ACCEPT && \
iptables -P INPUT DROP && \
iptables -P OUTPUT DROP && \
sleep 100 && \
iptables -P INPUT ACCEPT && \
iptables -P OUTPUT ACCEPT && \
iptables -F

Пока узел изолирован, в основную сеть уходит test 1:

gorgona send "$(date -u '+%Y-%m-%d %H:%M:%S')" "$(date -u -d '+30 days' '+%Y-%m-%d %H:%M:%S')" "test 1" "4YzEYpwB9hc=.pub"
# Alert ID: 219260927856640

На изолированный узел в это же время приходит test 2:

gorgona send "$(date -u '+%Y-%m-%d %H:%M:%S')" "$(date -u -d '+30 days' '+%Y-%m-%d %H:%M:%S')" "test 2" "4YzEYpwB9hc=.pub"
# Alert ID: 219260995915776

test 1 создан на 17 секунд раньше test 2, но изолированный узел об этом не знает.

Логи

Изолированный узел при получении test 2:

Alert 219260995915776 added to chain at pos 7 [APPEND] [Hash: 0x89bd9f1d5c47a1f8]

После сброса iptables и восстановления связи с мешем:

Alert 219260927856640 added to chain at pos 7 [BACKFILL] [Hash: 0x1755f33b65e2cfdb]

Позиция 7 занята дважды: сначала [APPEND], затем [BACKFILL]. Это и есть момент ребейза.

Выдача подписчика на этом узле после синхронизации:

Received Alert: Recipient_Hash=4YzEYpwB9hc=
Alert ID: 219260927856640
Hash in the chain: 15371510522798048946
Timestamps (Local): Created: 2026-09-12 15:34:59
Decrypted Content:
test 1

Received Alert: Recipient_Hash=4YzEYpwB9hc=
Alert ID: 219260995915776
Hash in the chain: 17545453266915102433
Timestamps (Local): Created: 2026-09-12 15:35:16
Decrypted Content:
test 2

Хронология восстановлена, хеши пересчитаны, обе записи на месте.

Механика ребейза

Шаг 1. Форк

Изолированный узел получает test 2 и кладёт его в позицию 7, привязав к Предку 6. В основной сети в это же время появляется test 1 от того же предка.

Основная сеть:      [Предок 6] -> [test 1]
Изолированный узел: [Предок 6] -> [test 2]

Шаг 2. Сверка вершин

После восстановления связи process_chain_sample отматывает историю назад и находит общего предка по совпадению id и curr_hash. Это Предок 6. Всё, что после него, расходится. Пир из меша отправляет изолированному узлу отсутствующий test 1.

Шаг 3. BACKFILL и сдвиг массива

add_alert() получает test 1 и сравнивает Snowflake ID:

ID(test 1) = 219260927856640
ID(test 2) = 219260995915776

test 1 создан раньше, значит должен стоять перед test 2. find_insert_position() возвращает позицию 7, но она занята. Массив сдвигается:

/* Shift memory to accommodate the new alert */
memmove(&rec->alerts[insert_pos + 1],
        &rec->alerts[insert_pos], ...);

test 2 переезжает в позицию 8, test 1 записывается в позицию 7.

Шаг 4. Каскадный пересчёт

После вставки запускается пересчёт цепочки от точки вставки:

/* 1. test 1 привязывается к общему Предку 6 */
new_alert->prev_hash = rec->alerts[6].curr_hash;
new_alert->curr_hash = alert_chain_compute_link(...); // 15371510522798048946

/* 2. Каскадный пересчёт всех последующих алертов */
uint64_t running_prev = new_alert->curr_hash;
for (int i = pos; i < rec->count; i++) {
    Alert *cur = &rec->alerts[i];   // здесь сдвинутый test 2
    cur->prev_hash = running_prev;  // его родителем теперь test 1
    cur->curr_hash = alert_chain_compute_link(cur->id, cur->prev_hash, cur->content_hash);
}

Хеш test 2 меняется:

  • до ребейза: 0x89bd9f1d5c47a1f8, родитель Предок 6;

  • после ребейза: 17545453266915102433, родитель test 1.

Шаг 5. Возврат в меш

Исцелённая цепочка:

Предок 6 -> test 1 (pos 7) -> test 2 (pos 8)

Узел делает broadcast_replication для test 2, остальные принимают его как [APPEND] в позицию 8.

Инварианты

Механизм держится на трёх свойствах:

  1. Snowflake ID задаёт тотальный порядок. Голосование, лидер, кворум не нужны, достаточно сравнить два числа.

  2. Общий предок находится детерминированно по паре id + curr_hash. Всё после него пересчитывается локально.

  3. Пересчёт каскадный и детерминированный: одинаковый вход даёт одинаковый curr_hash на всех узлах.

Отличия от существующих решений

Блокчейн и Git хранят всю историю. Биткоин занимает около 896 ГБ, репозитории Git растут без ограничений.

Redis и Kafka используют скользящее окно, но не хранят хеш‑цепочку. При удалении старых записей теряется якорь, сверять нечего.

Здесь оба подхода совмещены. Старые алерты можно вытеснять по окну, целостность сохраняется, потому что порядок задаётся Snowflake ID, а не физическим смещением в файле. Хеш‑цепочка пересчитывается локально.

Практические свойства

  • Узел переносит изоляцию любой длительности и возвращается в меш без ручного вмешательства.

  • Форк ликвидируется локально, данные, отправленные в изоляции, догоняют сеть.

  • E2E‑шифрование не затрагивается: ребейз работает только с метаданными цепочки.

  • Минимальное потребление ресурсов, пример: на 1 процессорной vps с 1гб ram
    в сети из 5 нод, под нагрузкой от 2х кластеров постгрес в режиме файловера и других метрик, всего 11 каналов, утилизация ram 18mb утилизация cpu 0.0% io util 0.08%

Стенд воспроизводится через iptables, поведение подтверждено логами. Код и обсуждение

Комментарии (0)