Атомарность в реактивных системах

от автора

Ранее мы говорили о чрезвычайных ситуациях при вычислении инвариантов. Однако они также могут возникать и во время одновременных изменений нескольких исходных состояний. Давайте посмотрим, какие тут есть варианты поведения:

👻 Alone: Атомарность отдельных состояний
🦶 Base: Атомарность группы первичных состояний
🤼 Full: Полная атомарность

👻 Alone: Атомарность отдельных состояний

Как правило, изменение одного состояния везде атомарно. То есть оно либо произойдет, либо нет. Рассмотрим простой пример: нам нужно обновить два состояния, но после обновления первого возникла аномальная ситуация…

Name = 'John'Count = 4Name = 'Jin'throw 'function is not a function'Count = 3 // все еще 4

В результате получается несогласованное состояние приложения. Ведь одно состояние обновилось, а второе — нет.

Можно обойти эту проблему, сохраняя оба значения в одном состоянии. Но это не всегда возможно.

🦶 Base: Атомарность группы первичных состояний

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

Name = 'John'Count = 4@transaction update() {  this.Name = 'Jin' // останется 'John'  throw 'function is not a function'  this.Count = 3}

🤼 Full: Полная атомарность

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

Name = 'John'Count = 4@derived get Greeting() {  // Ошибка при имени на 'Jin'  return this.Name.split('')[3].toUppercase()}@transaction update() {  this.Name = 'Jin' // останется 'John'  this.Count = 3 // останется 4}

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

Что выбрать?

Пока мы работаем с состоянием в памяти, мы полностью контролируем его, реализуя любую форму транзакций. Но приложения, живущие только в ОЗУ, мало полезны. А когда источник истины вынесен во внешнее хранилище, всё становится гораздо интереснее. Особенно если это несколько разнородных хранилищ, работу с которыми нельзя объединить в транзакцию.

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

Теперь рассмотрим один из основных сценариев: взаимное связывание двух заметок друг с другом. Для этого нужно последовательно обновить обе заметки, связав их. Но проблема в том, что если мы просто запишем первую, а при записи второй случится что‑то неожиданное (например, достигнут лимит хранилища), то получим несогласованное состояние: одна заметка считает другую связанной, а вторая ничего об этом не знает.

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

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

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

Та же проблема возникает при попытке откатить состояние хранилища, если между изменением хранилища и его откатом застряла другая транзакция с изменениями. В этом случае мы откатим не только свои изменения, но и, возможно, чужие. И наоборот, другая транзакция может отменить наш откат.

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

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

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

Например, в случае с zettelkasten можно показывать не все подряд в списке связанных заметок, а только те, с которыми есть взаимная связь. То есть это будет выглядеть как «откат транзакции», несмотря на то, что по сути отката не было, а база данных вообще в несогласованном состоянии.

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

А сейчас, подписывайтесь на что‑нибудь, вступайте во что‑то там, и держите руку на пульсе вот этого вот.

ссылка на оригинал статьи https://habr.com/ru/articles/1063664/