Утром человек открыл приложение на смартфоне без стабильного интернета и добавил две задачи. Позже за компьютером он изменил настройки и дополнил дневник. Когда телефон снова появился в сети, обе версии уже содержали полезные изменения.
Какую из них считать правильной?
Если просто загрузить облачную версию на телефон, исчезнут новые задачи.
Если отправить телефонную версию в облако, пропадут изменения с компьютера.
Если выбрать объект с самым поздним 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 revisionRetry распределённой операции должен повторять намерение пользователя, а не обязательно тот же сетевой запрос.
У нас есть ещё один уровень revision — локальный
Проблема может возникнуть даже без второго устройства.
Пользователь открыл Ritm в двух вкладках.
Обе получили одну локальную версию workspace.
Вкладка A сохраняет изменение.
Потом вкладка B, которая всё ещё держит старую копию, тоже пытается сохранить данные.
Если IndexedDB просто принимает последнюю запись, B может уничтожить изменение A.
Поэтому локальное сохранение использует _localRevision.
Внутри транзакции читается текущая revision:
current revisionи сравнивается с revision workspace, который пытается сохранить вкладка.
Если значения не совпадают, запись прерывается.
Пользователь получает сообщение о локальном конфликте вместо silent overwrite.
Таким образом защита работает в двух местах:
между вкладками одного устройства
→ local revision
между устройствами через cloud
→ server revision + three-way mergeAutosync тоже может создать 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.