Архитектура и данные

Как мы реализовали three-way merge для offline-first приложения без перезаписи данных пользователя

Синхронизация между устройствами выглядит простой до первого настоящего конфликта. Есть данные на телефоне. Есть их копия в облаке. Пользователь нажимает «Синхронизировать». Кажется, достаточно определить, какая версия новее, и сохранить её. Именно здесь начинается проблема. Представим обычную ситуацию.

Утром человек открыл приложение на смартфоне без стабильного интернета и добавил две задачи. Позже за компьютером он изменил настройки и дополнил дневник. Когда телефон снова появился в сети, обе версии уже содержали полезные изменения.

Какую из них считать правильной?

Если просто загрузить облачную версию на телефон, исчезнут новые задачи.

Если отправить телефонную версию в облако, пропадут изменения с компьютера.

Если выбрать объект с самым поздним updatedAt, результат принципиально не меняется: одна версия целиком уничтожит часть другой.

С этой проблемой мы столкнулись при разработке Ritm — одного из проектов WebRuta.

В итоге обычную схему «локальные данные против облачных» мы заменили на three-way merge:

              BASE
            /      \
           /        \
       LOCAL        REMOTE
           \        /
            \      /
             MERGE
               ↓
             RESULT

Но сама merge-функция оказалась только одной частью решения.

Нам понадобились сохранённая база последней успешной синхронизации, объединение коллекций по ID, отдельная политика для удаления, журнал реальных конфликтов, optimistic revision на сервере, повторное объединение после HTTP 409, recovery snapshot перед заменой данных и защита от параллельных синхронизаций.

В этой статье разберём, зачем всё это понадобилось и почему «последняя запись побеждает» оказалась слишком опасной моделью для приложения, которому пользователь доверяет личные данные.


Откуда вообще появляется конфликт

Offline-first приложение отличается от обычного сайта одной принципиальной вещью.

Отсутствие сети не должно означать:

Вы ничего не можете изменить.

Пользователь продолжает работать локально.

Условно в момент последней успешной синхронизации у нас было:

Профиль:
Имя: Михаил

Настройки:
Тема: светлая

Задачи:
- Подготовить отчёт

Назовём это состояние base.

После этого телефон оказался offline.

На телефоне пользователь поменял имя и создал задачу:

LOCAL

Профиль:
Имя: Михаил В.

Настройки:
Тема: светлая

Задачи:
- Подготовить отчёт
- Купить билеты

Тем временем на компьютере была изменена тема и добавлена другая задача:

REMOTE

Профиль:
Имя: Михаил

Настройки:
Тема: тёмная

Задачи:
- Подготовить отчёт
- Позвонить клиенту

Если посмотреть только на две конечные версии, нам неизвестно, что именно изменял каждый пользовательский сеанс.

Но если известна третья версия — base, ситуация становится понятнее.

Мы видим:

profile.name

base   = Михаил
local  = Михаил В.
remote = Михаил

Следовательно, поле изменилось только локально.

А для темы:

settings.theme

base   = light
local  = light
remote = dark

изменение произошло только на удалённой стороне.

Это и есть основная идея three-way merge.


Почему двух версий недостаточно

Предположим:

local  = dark
remote = light

Какая версия правильная?

Мы не знаем.

Но если добавить:

base = light

становится видно:

remote == base
local != base

Значит, изменялся только local.

Берём его.

Другой вариант:

base   = light
local  = light
remote = dark

Теперь изменился только remote.

Берём его.

Настоящий конфликт возникает только здесь:

base   = system
local  = light
remote = dark

Обе стороны независимо изменили одно поле.

И только теперь системе действительно требуется политика разрешения конфликта.

Это намного точнее, чем объявлять конфликтом любую разницу между двумя JSON.


Какую модель мы использовали

В Ritm merge-функция получает три workspace:

mergeWorkspaces({
    base,
    local,
    remote
})

Где:

base — состояние после последней успешной синхронизации;

local — текущее состояние на устройстве;

remote — актуальная версия из облака.

После объединения получается:

merged workspace
+
conflicts[]

Нам было важно не только получить какой-то результат, но и понимать, где именно алгоритму пришлось самостоятельно разрешать неоднозначность.

Поэтому конфликт имеет примерно такую семантику:

path:
profile.name

kind:
concurrent-value-edit

resolution:
local-preferred

Это намного полезнее безмолвного выбора одного значения.


Сначала устраняем случаи, которые вообще не являются конфликтами

В основе merge лежат несколько достаточно простых правил.

Если local и remote одинаковы:

local == remote

никакого конфликта нет.

Берём это значение.

Если локальная сторона не изменилась относительно базы:

local == base

значит изменение сделал remote.

Берём удалённое значение.

Если:

remote == base

значит менялся только local.

Берём локальное.

Упрощённо:

if (local === remote)
    return local;

if (local === base)
    return remote;

if (remote === base)
    return local;

Настоящая логика использует глубокое сравнение, потому что workspace состоит из вложенных объектов и массивов.

Эти три проверки убирают огромное количество ложных конфликтов.


Независимые правки должны складываться, а не конкурировать

Возьмём объект:

settings
├── theme
├── morningReminder
└── eveningReminder

На телефоне пользователь изменил:

morningReminder
08:00 → 07:30

На компьютере:

theme
light → dark

Это изменения одного объекта, но разных его полей.

Заменять весь settings целиком нельзя.

Поэтому объекты mergeятся рекурсивно:

base.settings
      ↓
для каждого key
      ↓
mergeValue(
    base[key],
    local[key],
    remote[key]
)

Результат:

theme = dark
morningReminder = 07:30

Оба изменения сохраняются.

Для пользователя именно это и означает нормальную синхронизацию:

Я изменил разные вещи на двух устройствах, и приложение не заставило меня выбирать, какое из устройств было «правильным».

С массивами всё сложнее

Workspace Ritm содержит множество коллекций:

tasks
habits
goals
personalNotes
decisions
experiments
capsules
...

Если представить их как обычный массив и выбрать целиком одну сторону, мы снова можем потерять данные.

Например:

BASE
[t1]

LOCAL
[t1, t2]

REMOTE
[t1, t3]

Обычный local-preferred даст:

[t1, t2]

и задача t3 исчезнет.

remote-preferred уничтожит t2.

Нам требовалось:

[t1, t2, t3]

Коллекции сущностей объединяем по ID

Если массив состоит из объектов с устойчивым строковым id, Ritm рассматривает его не просто как последовательность элементов, а как коллекцию сущностей.

Например:

tasks = [
    { id: "t1", ... },
    { id: "t2", ... }
]

Во время merge для base, local и remote строятся отображения:

id → object

Затем формируется множество всех ID:

base IDs
+
local IDs
+
remote IDs

После чего каждый объект mergeится отдельно.

Концептуально:

for (const id of allIds) {
    result[id] = mergeValue(
        base[id],
        local[id],
        remote[id]
    );
}

Именно поэтому два устройства могут независимо создать две задачи.

Телефон:

t2 = Купить билеты

Компьютер:

t3 = Позвонить клиенту

После синхронизации обе остаются.


Даже одна задача может изменяться независимо по разным полям

Теперь более интересная ситуация.

Есть:

Task t1

title: Подготовить отчёт
duration: 30
energy: medium

На телефоне пользователь меняет:

duration: 45

На компьютере:

energy: high

Поскольку сама задача определяется одним ID, merge не выбирает один объект целиком.

Он рекурсивно идёт внутрь t1.

Получается:

title: Подготовить отчёт
duration: 45
energy: high

Снова сохранены обе независимые правки.

Это одна из причин, почему устойчивые ID настолько важны для offline-first модели.

Без них алгоритму пришлось бы угадывать:

Это один и тот же объект или два разных?

Удаление оказалось одним из самых неприятных сценариев

Пусть в base существует задача:

t1 = Подготовить отчёт

На телефоне её удалили.

Получается:

LOCAL:
t1 отсутствует

На компьютере ничего не меняли:

REMOTE:
t1 всё ещё равен BASE

В таком случае удаление можно уважать.

Пользователь действительно удалил объект, а вторая сторона просто ещё не получила это изменение.

Результат:

t1 удаляется

Но теперь изменим сценарий.

На телефоне задачу удалили.

На компьютере её переименовали:

Подготовить квартальный отчёт

Получается:

BASE:
t1 существует

LOCAL:
t1 отсутствует

REMOTE:
t1 изменён

Вот это уже настоящий конфликт.


Почему мы не позволяем удалению молча уничтожить изменённый объект

В такой ситуации возможны две философии.

Первая:

Удаление сильнее изменения.

Тогда объект исчезает.

Вторая:

Если одна сторона продолжала работать с объектом, лучше временно сохранить его и зарегистрировать конфликт.

В Ritm выбран второй вариант.

Алгоритм записывает:

kind:
delete-vs-change

resolution:
changed-remote-kept

и сохраняет изменённую remote-версию.

Обратный случай:

LOCAL:
объект изменён

REMOTE:
объект удалён

даёт:

change-vs-delete

changed-local-kept

Для приложения с личными данными это сознательно консервативная стратегия.

Ошибочно оставить объект обычно безопаснее, чем безвозвратно уничтожить изменение, сделанное пользователем.


Это не универсально правильное решение

И здесь важно не выдавать конкретную политику за математическую истину.

В банковской системе правила могут быть другими.

В системе совместного редактирования документов может потребоваться отдельный интерфейс разрешения конфликта.

В CRM удаление объекта иногда вообще запрещено после определённого состояния.

Three-way merge не говорит:

Всегда сохраняй изменённую сторону.

Он лишь позволяет точно определить:

Здесь произошло одновременное удаление и изменение.

А уже продуктовая логика решает, что с этим делать.

Для Ritm потеря пользовательской информации была более опасным исходом, чем сохранение объекта, который на одном устройстве пытались удалить.


Что делать, если обе стороны изменили одно значение

Например:

BASE:
profile.name = Михаил

LOCAL:
profile.name = Михаил В.

REMOTE:
profile.name = Mikhail

Автоматически объединить эти значения нельзя.

Это реальный:

concurrent-value-edit

В текущей реализации Ritm действует deterministic policy:

local-preferred

То есть сохраняется локальное значение, а конфликт записывается.

Почему не случайный выбор?

Потому что merge должен быть воспроизводимым.

Почему не последнее по времени?

Потому что timestamp устройства не всегда является надёжным доказательством намерения пользователя.

Почему вообще автоматическое разрешение?

Потому что приложение в первую очередь персональное, а не collaborative editor с несколькими людьми, одновременно работающими над одним абзацем.

При этом конфликт не исчезает бесследно.

Он попадает в техническую информацию синхронизации.


Путь конфликта оказался очень полезным

Недостаточно записать:

Обнаружено 3 конфликта.

Для диагностики нужно понимать, где они произошли.

Поэтому merge сохраняет путь:

profile.name

или, например:

tasks.id:t1.title

Концептуально конфликт выглядит так:

{
    path: "profile.name",
    kind: "concurrent-value-edit",
    resolution: "local-preferred"
}

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

Если после релиза внезапно тысячи конфликтов возникают на одном и том же пути, это уже сигнал пересмотреть модель данных или merge policy.


Простые списки объединяются иначе

Не каждый массив является коллекцией объектов.

Например, есть список строк:

lifeAreas = [
    "Работа",
    "Семья"
]

На одном устройстве добавили:

"Здоровье"

На другом:

"Обучение"

Если обе стороны изменились, для простых массивов строк, чисел или boolean Ritm использует union.

Получается:

Работа
Семья
Здоровье
Обучение

А в журнале появляется:

concurrent-list-edit
resolution: union

Для подобных наборов это полезнее, чем выбрасывать одну сторону.


Но объединять union вообще любые массивы опасно

Представим массив, где порядок имеет значение.

Например:

[
    "первый шаг",
    "второй шаг",
    "третий шаг"
]

На двух устройствах порядок менялся независимо.

Просто взять union недостаточно.

Поэтому в текущей реализации есть три категории массивов:

массив объектов с id
→ merge по id

массив примитивов
→ union

остальные массивы
→ local-preferred + conflict

Для третьего случая фиксируется:

concurrent-array-edit

Это сознательное ограничение.

Мы предпочли явно зарегистрировать неоднозначность вместо попытки придумать «умный» алгоритм, который в редком случае незаметно соберёт неправильные данные.


У three-way merge должна быть настоящая BASE

Это важнейшая часть всей схемы.

Если каждый раз использовать:

base = remote

на момент синхронизации, теряется история расхождения.

Поэтому после успешной cloud sync Ritm сохраняет локальную копию синхронизированного workspace отдельно.

Она становится новой базой.

Условно:

успешный sync #18

workspace:
X

↓ сохранить

cloud-base = X

После этого устройства могут расходиться:

X
├── local changes → L
└── cloud changes → R

Следующая синхронизация выполняется как:

merge(X, L, R)

После успешного сохранения:

base = merged result

И цикл начинается заново.


База синхронизации — чувствительные данные

Здесь возник ещё один момент.

Ritm может содержать дневниковые записи и другие личные данные.

Если основной локальный workspace защищён PIN-vault, а base для merge лежит рядом незашифрованным JSON, защита становится частичной.

Поэтому сохранённая cloud base попадает в private cache.

При активном PIN-vault этот cache тоже шифруется.

Получается:

Local workspace
        ↓
   PIN protected

Recovery snapshots
        ↓
   PIN protected

Cloud merge base
        ↓
   PIN protected

Служебная копия данных не должна иметь более слабую защиту только потому, что пользователь никогда не видит её в интерфейсе.


Перед объединением мы создаём recovery snapshot

Merge — потенциально разрушительная операция.

Даже при хорошо протестированном алгоритме остаются:

неожиданные старые данные;

повреждённый импорт;

новая версия schema;

редкое сочетание конфликтов;

ошибка будущей версии merge-кода.

Поэтому до автоматического объединения с существующей облачной версией приложение сохраняет локальный recovery snapshot.

Логика примерно такая:

Получили REMOTE
      ↓
Сохранили LOCAL recovery snapshot
      ↓
three-way merge
      ↓
нормализация
      ↓
локальное сохранение
      ↓
cloud PUT

Это создаёт ещё один уровень защиты.

Merge должен быть правильным.

Но архитектура не должна предполагать, что сложная операция никогда не ошибётся.


После merge данные обязательно проходят нормализацию

Это ещё одна важная деталь реализации.

Результат рекурсивного объединения не сразу становится новым workspace.

Он проходит через:

normalizeWorkspace()

Почему это нужно?

Потому что local и remote могут происходить из разных версий приложения.

Например, одна версия ещё не знает о новом поле.

Или старый экспорт имеет неполную структуру.

Или вложенный объект отсутствует.

Нормализатор восстанавливает ожидаемую форму workspace и defaults.

Получается двухэтапная модель:

merge
↓
какие пользовательские изменения сохранить

normalize
↓
какую структуру должна иметь текущая версия приложения

Это разные задачи, и смешивать их в одну функцию было бы значительно сложнее.


Служебные поля нельзя mergeить как пользовательские данные

В workspace существуют технические поля.

Например:

_localRevision
updatedAt
_syncInfo

Если пропустить их через тот же алгоритм, можно получить бессмысленные конфликты:

_localRevision:
12 vs 13

_syncInfo.mergedAt:
...

Но это не пользовательские изменения.

Поэтому перед merge такие поля удаляются из сравниваемых данных.

Условно:

delete workspace._localRevision;
delete workspace.updatedAt;
delete workspace._syncInfo;

После merge служебная информация создаётся заново.

Это небольшой, но важный принцип:

metadata синхронизации не должна сама становиться предметом синхронизации.


Первый sync — особый случай

Three-way merge предполагает наличие base.

Но что делать, если пользователь только что вошёл на новом устройстве и сохранённой базы ещё нет?

Можно было бы использовать пустой объект:

{}

Но это создаёт проблему.

Некоторые значения workspace являются нормальными defaults.

Например, системная тема или стандартные настройки.

Пустой объект не отражает их семантику.

Поэтому, когда сохранённой base ещё нет, merge идёт относительно:

defaultWorkspace()

Это позволяет отличить:

пользователь действительно изменил значение

от:

здесь просто стандартное значение новой установки.

В regression-тесте этот сценарий проверяется отдельно.


Одного three-way merge всё равно недостаточно

Представим следующую ситуацию.

Устройство A получает cloud revision:

revision = 17

Выполняет merge и готовится сохранить результат.

Но за эти несколько сотен миллисекунд устройство B уже записало новую версию:

revision = 18

Если сервер просто принимает следующий PUT, устройство A опять перезапишет свежие данные.

То есть отличный three-way merge можно уничтожить обычным race condition на последнем шаге.

Поэтому у облачного workspace есть revision.


Сервер принимает запись только поверх известной версии

Клиент отправляет:

{
  "revision": 17,
  "workspace": { }
}

На сервере обновление выполняется условно.

В PostgreSQL концептуально это выглядит так:

UPDATE workspaces
SET payload = ...,
    revision = revision + 1
WHERE user_id = ?
  AND revision = 17

Если строка обновилась — никто не изменял workspace после того, как клиент получил revision 17.

Если нет — версия устарела.

Сервер возвращает:

409 Conflict

code:
SYNC_CONFLICT

И главное: он не принимает устаревший workspace.

Это optimistic concurrency control.


Почему мы не используем блокировку на всё время синхронизации

Теоретически можно было бы заблокировать cloud workspace:

устройство A начало sync
→ всем остальным ждать

Но пользовательские устройства ненадёжны.

Телефон может потерять сеть.

Вкладка может закрыться.

Приложение может уйти в background.

Держать распределённую блокировку вокруг всей пользовательской операции намного сложнее.

Optimistic concurrency исходит из другого предположения:

Большинство записей не столкнутся. Если столкнутся — мы это обнаружим по revision и пересчитаем результат.

Для нашего сценария это значительно проще и надёжнее.


Что происходит после 409

Получить 409 и показать:

Ошибка синхронизации

было бы безопасно, но неудобно.

Мы пошли дальше.

Клиент снова запрашивает актуальную облачную версию.

Получается:

мы рассчитывали merge поверх:
REMOTE 17

но сервер уже имеет:
REMOTE 18

Тогда выполняется ещё один three-way merge.

На этот раз:

base   = remote 17
local  = уже объединённое локальное состояние
remote = remote 18

После него клиент пытается сохранить результат поверх revision 18.

Если за это время появился revision 19 — процесс может повториться.

В текущей реализации предусмотрено до трёх попыток.

То есть алгоритм не делает blind retry одного и того же payload.

Он каждый раз пересчитывает merge относительно новой реальности.


Почему обычный retry здесь опасен

Представим:

PUT revision 17
→ 409

Наивный retry:

ещё раз PUT revision 17

ничего не изменит.

Ещё хуже:

PUT force=true

Он может уничтожить revision 18.

Поэтому правильная последовательность другая:

409
 ↓
GET latest remote
 ↓
merge again
 ↓
PUT against latest revision

Retry распределённой операции должен повторять намерение пользователя, а не обязательно тот же сетевой запрос.


У нас есть ещё один уровень revision — локальный

Проблема может возникнуть даже без второго устройства.

Пользователь открыл Ritm в двух вкладках.

Обе получили одну локальную версию workspace.

Вкладка A сохраняет изменение.

Потом вкладка B, которая всё ещё держит старую копию, тоже пытается сохранить данные.

Если IndexedDB просто принимает последнюю запись, B может уничтожить изменение A.

Поэтому локальное сохранение использует _localRevision.

Внутри транзакции читается текущая revision:

current revision

и сравнивается с revision workspace, который пытается сохранить вкладка.

Если значения не совпадают, запись прерывается.

Пользователь получает сообщение о локальном конфликте вместо silent overwrite.

Таким образом защита работает в двух местах:

между вкладками одного устройства
→ local revision

между устройствами через cloud
→ server revision + three-way merge

Autosync тоже может создать race condition

Когда синхронизация стала автоматической, появился ещё один вопрос.

Локальное изменение запускает autosync с небольшой задержкой.

В этот момент может произойти другое изменение.

Потом восстановилась сеть.

Потом пользователь вручную нажал синхронизацию.

Если запустить три процесса одновременно, они начнут конкурировать между собой.

Поэтому в клиенте есть:

syncInProgress

и:

syncQueued

Если sync уже идёт:

новый запрос
↓
не запускаем второй
↓
syncQueued = true

После завершения текущего прохода запускается один последующий sync.

Несколько событий таким образом схлопываются.

Это простой механизм, но он предотвращает создание конфликтов самим механизмом синхронизации.


После локальных изменений sync не запускается на каждый символ

У пользователя могут быстро происходить десятки операций.

Например, он редактирует настройки или несколько записей подряд.

Нам не хотелось после каждого сохранения мгновенно выполнять полный cloud round trip.

Поэтому autosync debounced.

В текущей реализации обычная задержка составляет около 1,8 секунды.

Если за это время происходит ещё одно изменение, таймер переустанавливается.

Схема получается такой:

edit
 ↓
edit
 ↓
edit
 ↓
1,8 сек тишины
 ↓
sync

После возвращения сети синхронизация ставится быстрее.

Это компромисс между:

мгновенно синхронизировать всё

и:

ждать ручной кнопки

Offline-first означает, что сеть — событие, а не условие работы приложения

Это, пожалуй, главное продуктовое следствие.

В обычной cloud-first модели логика может быть:

нет сети
→ нельзя сохранить

В Ritm:

нет сети
→ локальное сохранение продолжается
→ изменения расходятся с cloud
→ сеть возвращается
→ merge

То есть offline — не аварийное состояние данных.

Это нормальная ветка жизненного цикла.

Но за это приходится платить архитектурной сложностью.

Если приложение должно позволять пользователю полноценно работать без сети, разработчику нужно заранее ответить:

Как мы потом соединим две изменившиеся реальности?

Почему last write wins был особенно опасен именно для Ritm

Ritm хранит не просто настройки интерфейса.

В workspace находятся:

дневник;

задачи;

личные заметки;

цели;

решения;

эксперименты;

история дня.

Потерять изменение темы с light на dark неприятно.

Потерять запись дневника, сделанную offline, — уже совсем другой класс проблемы.

Поэтому простая модель:

newer updatedAt wins

не соответствовала ценности данных.

Чем труднее человеку восстановить информацию из памяти, тем осторожнее должна быть стратегия автоматического разрешения конфликтов.


Что происходит с историей задач при merge

В версии 3.0.2 в workspace уже существует отдельная история задач по дням.

Это тот самый случай, где текущий объект и исторический факт имеют разные функции.

Live-задача может быть перенесена или удалена.

taskHistory хранит snapshot того, что происходило относительно конкретного дня.

Для синхронизации это означает ещё одну важную вещь:

исторические записи тоже являются пользовательскими данными и должны корректно переживать объединение устройств.

Если две независимые ветки workspace содержат разные дни, рекурсивный object merge сохраняет обе.

Если внутри ID-коллекций появились разные сущности, они объединяются по ID.

Таким образом offline-first merge и историческая модель дополняют друг друга:

task-history
→ не позволяет настоящему переписать прошлое

three-way merge
→ не позволяет одному устройству переписать другое

Это разные проблемы, но фундаментальная идея похожа:

не уничтожать достоверную пользовательскую информацию только потому, что появилась новая версия состояния.


Что мы намеренно не пытались решить автоматически

Любой merge-алгоритм имеет границы.

Текущая модель хорошо работает там, где:

объекты имеют стабильные ID;

изменения часто затрагивают разные поля;

простые списки допускают union;

редкие scalar-конфликты можно разрешить deterministic policy.

Она не превращает приложение в Google Docs.

Если два человека одновременно редактируют один длинный текст на уровне символов, нужен другой класс алгоритмов: OT, CRDT или специализированное совместное редактирование.

Если порядок элементов является бизнес-критичным, простой union недостаточен.

Если конфликт несёт юридические или финансовые последствия, local-preferred может быть неприемлем.

Для персонального Ritm текущая сложность была оправдана.

Строить универсальную distributed collaboration platform для решения задачи синхронизации одного пользователя было бы избыточно.


Почему мы записываем конфликты, даже если смогли их разрешить

Можно задать вопрос:

Если алгоритм всё равно выбрал результат, зачем хранить конфликт?

Потому что автоматическое разрешение и отсутствие конфликта — разные вещи.

Например:

BASE:
name = A

LOCAL:
name = B

REMOTE:
name = C

Мы можем выбрать B.

Но это не означает, что C было неважно.

Журнал позволяет диагностировать такие ситуации и оценивать качество merge policy на реальных данных.

В workspace сохраняется:

_syncInfo

с временем последнего merge, количеством конфликтов и ограниченным набором их описаний.

В интерфейсе настроек пользователь может увидеть хотя бы факт:

Последнее объединение: ...
Конфликтов: 2

При этом внутренний журнал не разрастается бесконечно: детализированные записи ограничиваются, а полный conflictCount сохраняется отдельно.


Отладка merge без сценарных тестов почти бессмысленна

Функция объединения небольшая.

Но количество комбинаций состояний довольно быстро растёт.

Поэтому нас интересовали не только unit-проверки отдельных if, а реальные сценарии.

Один из тестов начинается с общей базы.

Локально меняется имя профиля и появляется новая задача.

Удалённо меняется тема, утренняя энергия и появляется другая задача.

После merge проверяется, что сохранились:

локальное имя;
удалённая тема;
удалённая энергия;
локальная новая задача;
удалённая новая задача.

Другой сценарий проверяет:

LOCAL:
задача удалена

REMOTE:
та же задача изменена

Результат должен сохранить изменённую задачу и зафиксировать:

delete-vs-change

Отдельно проверяется одновременное изменение scalar:

LOCAL name = One
REMOTE name = Two

И сценарий первого sync без сохранённой base.

На проверенном архиве этот regression-тест проходит полностью.


Мы проверяем не только merge-функцию

В том же тесте проверяется наличие механизмов автоматической синхронизации в реальном приложении:

scheduleAutoSync
skipAutoSync
online event
syncInProgress

Почему это важно?

Потому что корректная чистая функция mergeWorkspaces() сама по себе ещё не создаёт корректную синхронизацию.

Проблема может находиться на уровень выше.

Например:

merge запускается дважды параллельно;

после локального commit происходит рекурсивный autosync;

при возвращении сети ничего не запускается;

после конфликта используется старый remote.

Поэтому production-поведение нужно тестировать как систему, а не только как алгоритм.


В итоге у синхронизации получилось несколько защитных слоёв

Если посмотреть на архитектуру целиком, она выглядит примерно так:

Пользователь изменяет данные
          ↓
Локальный commit
          ↓
Local revision check
          ↓
IndexedDB
          ↓
Debounced autosync
          ↓
GET remote + revision
          ↓
Load saved BASE
          ↓
Recovery snapshot
          ↓
Three-way merge
          ↓
normalizeWorkspace()
          ↓
PUT with expected revision
      ↙             ↘
   success           409
      ↓               ↓
save new BASE     GET latest
      ↓               ↓
    done           merge again
                      ↓
                    retry

Ни один из этих механизмов по отдельности не решает всю проблему.

Three-way merge сохраняет независимые изменения.

Revision не позволяет устаревшему клиенту перезаписать свежий cloud.

Recovery snapshot позволяет отступить назад.

Нормализация защищает schema.

Local revision предотвращает конфликт вкладок.

Autosync coordination не позволяет самому клиенту запускать конкурирующие операции.

Именно комбинация этих решений дала нам приемлемое поведение offline-first продукта.


Что мы бы сделали иначе для большого collaborative-продукта

Если бы проект предназначался для одновременной работы десятков людей над одними объектами, текущую модель пришлось бы развивать.

Для некоторых сущностей понадобился бы server-side conflict resolver.

Для длинного совместно редактируемого текста — CRDT или OT.

Для важных конфликтов — пользовательский merge UI.

Для некоторых domain-событий — append-only event model.

Возможно, появились бы per-entity revisions вместо одной revision всего workspace.

Но это ещё один важный вывод из разработки Ritm:

архитектура должна соответствовать реальной вероятности конфликта, а не максимальной теоретически возможной сложности.

В нашем случае один пользователь работает со своими данными на нескольких устройствах.

Three-way merge с optimistic concurrency оказался значительно понятнее и дешевле, чем полноценная инфраструктура collaborative editing, но намного безопаснее, чем last-write-wins.


Почему синхронизация — продуктовая функция, а не инфраструктурная мелочь

Пользователь никогда не скажет:

Мне нравится ваша реализация recursive three-way merge.

Он скажет:

Хорошо, мои записи с телефона никуда не пропали.

Или, если система спроектирована плохо:

Я вчера всё заполнил, а сегодня этого нет.

Во втором случае объяснение про race condition или устаревшую cloud revision уже ничего не меняет.

Для приложений с личными данными синхронизация напрямую связана с доверием.

Пользователь может простить медленную анимацию.

Он гораздо хуже относится к исчезновению собственного текста.

Поэтому мы перестали рассматривать cloud sync как функцию:

загрузить JSON
+
скачать JSON

Для нас это механизм сохранения пользовательского намерения между несколькими расходящимися состояниями.


Главный вывод

Самая простая синхронизация выглядит так:

LOCAL → CLOUD

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

После появления второго устройства реальная модель становится другой:

         BASE
        /    \
       /      \
  устройство A
       \      /
        \    /
   устройство B

Оба конца могут изменяться.

Поэтому вопрос:

Какая версия новее?

оказался для нас неправильным.

Гораздо полезнее спрашивать:

Что изменилось относительно последнего общего состояния на каждой стороне?

Именно это даёт three-way merge.

Но после его реализации мы получили ещё один урок: хороший merge не отменяет необходимости защищать сам процесс записи.

Поэтому вокруг него появились server revision, HTTP 409, повторное объединение с актуальным remote, recovery snapshots, локальные revision, нормализация данных и координация autosync.

В результате система следует довольно простому принципу:

если два устройства создали две разные полезные части пользовательской истории, синхронизация должна сначала попытаться сохранить обе — и только там, где это действительно невозможно, применять явную и диагностируемую политику конфликта.

Для offline-first приложения это оказалось значительно важнее, чем просто добиться того, чтобы кнопка «Синхронизировать» возвращала HTTP 200.

Есть похожая задача?

Опишите продукт, интеграции и ограничения. До разработки зафиксируем объём, риски и критерии приёмки.

Обсудить проект →