Durability: доступность важнее согласованности
Доку про хранение обычно открывают тем, что она гарантирует. Эта открывается тем, от чего отказывается, — потому что именно эта половина нужна, чтобы решить, подходит ли amethystate тому, что вы строите.
Параллель с CAP вольная, но честная: перед выбором того же рода библиотека отдаёт согласованность. Durability затаскивает в пути, которые касаются состояния, асинхронность, а то и блокировку, — интерфейсу не нужно ни то, ни другое. Ждать чтения не хочет никто, ждать записи — почти никто. Согласованность здесь означает, что прочитанное совпадает с тем, что уже легло на диск, и платят этим совпадением ровно за то, чтобы оба пути остались обычными синхронными вызовами. Прочитанное всегда правда про состояние вашего приложения. Про то, что лежит на диске, — не всегда.
Всё ниже — форма этого решения: что оно даёт, чего стоит и где можно выкупить гарантию обратно, когда она нужна.
Как это работает
Заголовок раздела «Как это работает»Запись не доходит до диска в тот момент, когда вы её делаете. field.set() кладёт значение в буфер в памяти, оповещает подписчиков и возвращается. Чуть позже таймер дебаунса сбрасывает буфер в store, и всплеск записей в один путь стоит одного сброса, а не одного на каждую.
Одинаковая запись дальше сравнения не идёт. Байты сверяют с тем, что store уже держит — в буфере или зафиксированным, — и совпадение всё на этом заканчивает: ничего не буферизуют, никого не зовут, сброс не планируют. Ползунок, округлившийся к шагу, на котором и стоял; форма, сохраняющаяся по потере фокуса без единой правки; кэш, перепроверенный по таймеру, — каждое стоит одного memcmp. Сверяют именно байты, поэтому f64 с NaN дедуплицируется сам с собой.
Подписчики слышат про изменения. Повторная запись того же значения молчит, поэтому всему, чему нужно сказать проверено, всё ещё верно, нужно своё место, где это сказать.
Чтения дёшевы по той же причине. get() сперва заглядывает в этот буфер и отвечает из него, поэтому значение, в которое вы писали каждый кадр, читается обратно из памяти, а не из store.
Что вы получаете
Заголовок раздела «Что вы получаете»Чтения видят ваши собственные записи. Только что записанное значение get() отдаёт сразу, ещё до того, как оно доедет до диска: сперва заглядывают в буфер.
state.port().set(9090);
let reads_back = state.port().get();reads_back равно 9090, а документ на диске всё ещё держит прежнее значение.
Сбросы атомарны. Всё, что в буфере, уходит в store одной транзакцией. Наполовину записанной пачки в store не бывает.
Штатное завершение ничего не теряет. Дроп store сбрасывает буфер, и у процесса, вышедшего нормально, всё лежит на диске.
Кроме глобального store: его никто не дропает. Статик не дропают никогда, поэтому при обычном возврате из main последний интервал дебаунса пропадёт — если только не уйдёт из области видимости guard или не позвать amethystate::shutdown(). См. Открытие store.
Что вы теряете
Заголовок раздела «Что вы теряете»Падение уносит буфер. Процесс, убитый сигналом, свалившийся в панику или обрезанный отключением питания, теряет всё, записанное после последнего сброса: деструкторы в таких случаях не выполняются.
Окно — это интервал дебаунса, по умолчанию 300 мс:
let store = StoreBuilder::new(path.path()) .disk(|d| d.debounce(Duration::from_millis(50))) .build()?;Опустите — окно сузится, а сбросов станет больше. Поднимете — наоборот. Ручка тут одна, и крутит она ровно этот обмен.
Оповестили — не значит сохранили. Подписчиков зовут внутри set(), до сброса, и подписчик вполне может увидеть значение, которое сотрёт следующее падение. Если ваш колбэк делает что-то необратимое за пределами процесса — шлёт запрос, пишет другой файл, — не принимайте событие за доказательство, что значение уцелело.
В чём поле и store согласны
Заголовок раздела «В чём поле и store согласны»Всё, что выше, — про буфер и диск, и там диску позволено отставать. Между полем и store ответ строже: в поле то же, что в store. На этой гарантии библиотека и построена, и место, где она не держится, — ошибка, о которой стоит сообщить.
- Запись через поле ложится и туда, и туда — или никуда. Запись в закрытый store оставляет поле как было и пишет об этом в лог; значение, которое движок не удержит, вызывает панику раньше, чем куда-нибудь ляжет. Подписчик, который паникует, запись не отменяет: остальным подписчикам всё равно скажут, сброс уже запланирован, и только после этого паника дойдёт до пишущего.
- Изменение, пришедшее другим путём, — через
Store::setпо пути, правкой файла руками, вторым store на том же файле — доходит до поля таким, каким его принял store. - Объявленное правило, которое поправило значение, записывает поправленное обратно, откуда бы значение ни пришло, так что в файле не остаётся того, чего поле не показывает.
- После
saveзагруженная структура в руках держит то, что записано.
Где они вправе расходиться
Заголовок раздела «Где они вправе расходиться»Мест немного, каждое — нарочно, и про каждое поле можно спросить:
- Значение, которое поле не берёт. Байты, которые не декодируются;
default, который не удалось записать, потому что по пути уже что-то лежало. Поле показывает последнее хорошее значение илиdefault, аtry_getотвечаетErrи говорит почему. Store держит прежнее, так что ничьё записанное не пропадает, пока его кто-нибудь не исправит. - Ключ удалили под полем, которое держит значение. При
on_delete = Keep— а он стоит по умолчанию — поле показывает последнее значение, а в store там пусто до следующей записи. - Поправка, которую store не взял. Правило поправило сохранённое значение, а записать поправку обратно не вышло: store закрыт или движок не держит то, что сделало правило. Поле держит поправленное значение, store — то, что ему дали, а
try_getотвечаетReason::NotWrittenBack. - Загруженная структура между загрузкой и
save. Это обычные данные у вас в руках: присваивание ничего не меняет доsave, а правка извне до них не доходит вовсе. save, который store оборвал на полпути.saveсначала проводит все поля через их правила и только потом пишет. Но ошибка store посреди записи — store закрыт, движок не берёт значение — оставляет записанными поля до неё, иsaveназывает то, на котором остановился.- Закрытый store. Поле отвечает последним, что слышало, а
try_getговорит, что store закрыт. volatile-поле, которое не хранится нигде.
Конкурентный доступ
Заголовок раздела «Конкурентный доступ»Согласие держится, пока пишет кто-то один. Две записи в одно поле из двух потоков store ставит в очередь, и поле останавливается на той, что store принял последней. Чего гарантия не покрывает:
- Прочитать, изменить, записать — не атомарно.
updateиmodifyу поля и ячейки,modifyиupsertу карты читают значение, зовут ваше замыкание и пишут результат. Два потока сразу могут прочитать одно и то же, и вторая запись выбросит изменение первой. Поле и store при этом согласны — на второй записи. - Ничто не охватывает два поля. Два потока, пишущие связанные поля, могут оставить пару, которой не хотел ни один. Проверка между полями в
Open, написанном руками (open = manual), идёт один раз, при открытии, а не при каждой записи. - Другой процесс на том же файле — дело движка, оно расписано в разделе Закрытие: redb его не пустит, SQLite держит файл, а текстовые движки сливают записи при сохранении, и ключ, который писали оба, достаётся тому, кто сохранил последним. Пока наблюдатель не принёс чужую запись, процессы друг с другом не согласны.
Где это важно, дайте состоянию одного писателя: один поток или свой замок вокруг чтения и записи.
Как дождаться диска
Заголовок раздела «Как дождаться диска»save_now() выталкивает буфер целиком и возвращается тогда, когда store его зафиксировал.
state.port().set(9090);store.save_now()?;У полей, карт, ячеек и Kv есть проекция durable(): те же записи, только каждая возвращается, когда изменение уже на диске. Так гарантия сводится к одному вызову — и между записью и фиксацией не остаётся промежутка, в котором вас вытеснят или в котором вы просто забудете дописать:
state.port().durable().set(9090)?;Вне потока интерфейса есть set_async. Он ленив, как всякий future: пока его не дождутся, не происходит ничего — записи в том числе:
state.port().durable().set_async(9090).await?;Тянитесь за ними там, где последние несколько сотен миллисекунд действительно жалко потерять: перед запуском внешнего процесса, после шага, который человек не сможет повторить. Звать их на каждой записи — значит отказаться от тех дешёвых записей, ради которых вы сюда и пришли.
Durable-запись фиксирует соседей
Заголовок раздела «Durable-запись фиксирует соседей»port ниже пишут через durable(), а host — забуференный мгновением раньше и ничего такого не просивший — к возврату из вызова тоже оказывается на диске:
state.host().set("10.0.0.1".to_string());
state.port().durable().set(9090)?;Насколько широко это расходится, решает движок. Durable-запись кладёт значение в буфер, как любая другая, а потом сбрасывает тот путь, который записала. Текстовые движки при каждой фиксации переписывают документ целиком, так что одна durable-запись доводит до диска весь store — это и случилось выше. redb и sqlite фиксируют одной транзакцией то, что лежит в буфере по этому пути и ниже него: значение поля, все забуференные записи карты, один путь Kv. Там host дождался бы окна. Асинхронные близнецы ждут следующего полного сброса store, поэтому set_async на любом движке фиксирует всё, что лежит в буфере.
Отсюда два следствия, и оба стоит запомнить. Durable-запись стоит не вашего значения, а всего того, что эта фиксация унесёт с собой, — чего вы не выбирали и не видите. И значение, которое вы намеренно оставили в буфере, может уехать на диск потому, что зафиксировали соседнее: «ещё не durable» никогда не говорит, где значения нет.
Обходного пути нет
Заголовок раздела «Обходного пути нет»Прямо на диск не пишет ничто. durable() дожидается сброса, а буфер не обходит, — и нет вызова, который бы его обошёл.
Взамен вы получаете то, что иначе пришлось бы проверять самому. Бухгалтерия, которую amethystate ведёт для себя, — в частности отметка, что пространство имён инициализировано, — уходит в тот же буфер, что и значение, поэтому ложатся они вместе либо не ложатся вовсе. Падение не оставит вам ни пустого пространства имён, помеченного как инициализированное, ни значений по умолчанию, записанных вторым слоем поверх уже существовавших данных.