Перейти к содержимому

Настройка store

Всё, что здесь описано, задают до build, и относится оно ко всему store целиком. То, что читает любой движок, задают на StoreBuilder. То, что читает только один, задают на самом движке — Redb, Text — и передают его в StoreBuilder::backend: так настройка не попадёт к движку, который её проигнорирует. У каждого пункта есть значение по умолчанию, и трогать его обычно незачем; страница — про те случаи, когда всё-таки есть зачем.

let store = StoreBuilder::new(settings)
.disk(|d| {
d.debounce(Duration::from_millis(500))
.watch_every(Duration::from_secs(2))
})
.build()?;

Всё про то, когда этот store трогает свой файл, собрано под StoreBuilder::disk. Он передаёт внутрь значения по умолчанию и забирает обратно то, что вы поменяли, — записываются только названные вами настройки, и забытая не обнулит остальные.

Disk::debounce — через сколько после первой несохранённой записи придёт сброс. Записи, сделанные за это время, попадают в тот же сброс, а не откладывают его, так что поток, который не стихает, всё равно пишется раз в окно. Поднимете — в один коммит соберётся больше записей; опустите — сузится окно, которое может забрать падение. Чтениям всё равно и в ту, и в другую сторону: буферизованная запись видна сразу.

Сброс, который приходит по окну, на любом движке фиксирует весь буфер. Durable-запись просит сбросить тот путь, который записала, и докуда дотянется такой сброс, решает движок, а не эта настройка: redb и sqlite фиксируют то, что лежит в буфере по этому пути и ниже него, а текстовый переписывает файл целиком и потому фиксирует всё, что ждало. Следствия — в Durability.

Disk::watch_every работает в обратную сторону: сколько файл должен пролежать неподвижно, прежде чем правку, сделанную мимо процесса, прочитают обратно. Никто ничего не опрашивает — наблюдатель событийный, на inotify, ReadDirectoryChangesW или FSEvents, смотря по платформе. Это период тишины после последнего события, поэтому редактор, сохраняющий в несколько заходов, стоит одного перечитывания вместо нескольких.

watch_every — только про текстовые движки, те, чей файл человек может править руками. debounce работает у всех.

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

Настроить можно, как часто пробовать и как долго это тянется молча:

let store = StoreBuilder::new(settings)
.disk(|d| {
d.retry_every(Duration::from_secs(2))
.give_up_after(Duration::from_secs(30))
})
.build()?;

Disk::retry_every — промежуток между попытками. Disk::give_up_after — сколько может тянуться череда неудач, прежде чем store об этом скажет: один раз, на error под таргетом amethystate, и всем, кто следит за его сбросом. Это сколько неудачи длятся молча, а не сколько они длятся.

Пишущим не говорят никогда. Запись ложится в память, что бы ни творилось с диском, буфер её держит, и освобождённое место вылечит store хоть через час, без перезапуска. Забитый диск на многих машинах — дело обычное, и записи в поле с ним делать нечего — делать что-то должно приложение, один раз и в одном месте. Об ошибке узнаёт только тот, кто спросил, легла ли его запись на диск: durable(), save_now, ожидаемый сброс.

Слушать может сколько угодно мест уже у открытого store: строка состояния, телеметрия, тест.

let watch = store.on_persist_failure(|event| match event {
PersistEvent::GaveUp { failure } => match failure.why.current_context() {
StorageError::Flush => eprintln!("the disk is not taking writes: {:#}", failure.why),
_ => eprintln!("not saved: {:#}", failure.why),
},
PersistEvent::Recovered => eprintln!("saved again"),
_ => {}
});
if let Some(why) = store.persist_failure() {
eprintln!("the last save that gave up: {why:#}");
}

Наблюдатель слышит каждую череду неудач, пережившую give_up_after, а потом — сброс, который лёг после неё. Сам он ничего не решает. Он на месте, пока держат его watch, а выбросили watch — отцепился и наблюдатель. Работает он на потоке дебаунсера, и закрыть оттуда store нельзя; если наблюдатель паникует, это пишут в лог, а store сбрасывает дальше.

failure.why.current_context() говорит, что это за ошибка. StorageError::Flush — это диск: забит, только для чтения, отобран, занят кем-то ещё. StorageError::Codec — документ не сериализуется, и это отказ, который сам не пройдёт: на сотой попытке он такой же, как на первой. failure.unsaved — все пути, записанные с последнего удавшегося сброса. Это подозреваемые, а не виновный: документ рендерится целиком, и упавший рендер не называет пути.

Приходит она как Report<StorageError>, а не одним из наборов, и это нарочно: наблюдатель работает на потоке дебаунсера, отвечать ему некому, — значит, это отчёт движка, а не граница.

Store::persist_failure — те же новости для того, кто предпочитает спросить, а не слушать: ошибка, с которой сдалась последняя череда, пока сброс снова не ляжет.

Большинство ошибок кодека сюда и не доходит. Значение кодируют там же, где пишут, поэтому то, чего формат не держит, отклоняет сам set — в буфер оно не попадает.

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

let store = StoreBuilder::new(settings)
.backend(Text::json().file_write(|w| {
w.writing(WriteAttempts::times(3).apart(Duration::from_millis(50)))
.replacing(WriteAttempts::times(20).apart(Duration::from_millis(250)))
}))
.build()?;

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

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

Задаётся через limits, и про это есть отдельная страница: Чего store не примет.

let store = StoreBuilder::new(settings)
.backend(Redb::new().parallel_reads(true))
.build()?;

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

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

Это настройка redb и движка в памяти. SQLite и текстовые движки читают на том же потоке, с которого их позвали, и такой настройки у них нет.

let store = StoreBuilder::new(settings)
.backend(Redb::new().cache_size(64 << 20))
.build()?;

По умолчанию у redb кеша нет. Всё, что держат поля, ячейки и карты store, и так лежит в памяти, а собственный кеш redb был бы второй копией тех же данных и дорос бы до размера файла. Без него чтения, которые идут в файл, — get, чтение через Kv, скан — обслуживает файловый кеш системы.

Платят за это только эти чтения. На store из ста тысяч записей get занимает около 20 µs без кеша и 2 µs с ним, скан тысячи записей — около 300 µs против 100. Коммит одинаков в обоих случаях: он ждёт диск. Кеш стоит дать store, который читают в основном через Kv; размер задают в байтах.

migrations, provide и context сидят на том же билдере, но это входные данные: шаги, которые надо выполнить, значения, которые получат эти шаги, и значения, которые получат объявленные правила. Первые два разбирают Миграции, третье — Правила.

Два последних разделены потому, что читают их в разных местах. Шаг миграции выполняется один раз, внутри build, на том потоке, который build и позвал, поэтому provide берёт что угодно — Rc, хендл, который тулкит отказывается перемещать. Правило выполняется каждый раз, когда значение приходит или его записывают, в том числе с потока, следящего за файлом, поэтому context требует Send + Sync.