Настройка 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 держат собственный хендл и пишут через него — заменять и блокировать там нечего, и такой настройки у них нет.
Чего store не примет
Заголовок раздела «Чего store не примет»Задаётся через limits, и про это есть отдельная страница:
Чего store не примет.
Чтение большой коллекции
Заголовок раздела «Чтение большой коллекции»let store = StoreBuilder::new(settings) .backend(Redb::new().parallel_reads(true)) .build()?;Разобрать каждый сохранённый ключ и декодировать каждое значение — это около четырёхсот миллисекунд на открытии с миллионом записей; разложите работу по ядрам — станет примерно восемьдесят. По умолчанию выключено: это пул потоков внутри библиотеки состояния, и приложение, у которого свой пул уже есть, должно само сказать, нужен ли ему второй. Пока выключено, никто ничего не порождает — пул собирают при первом обращении.
Где-то ниже тысячи записей раздать работу дороже, чем сделать её, и там ничего не раскладывается.
Это настройка redb и движка в памяти. SQLite и текстовые движки читают на том же потоке, с которого их позвали, и такой настройки у них нет.
Кеш redb
Заголовок раздела «Кеш redb»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.