Открытие store
Store — тот хендл, из которого всё читают и через который всё пишут. Собирают
его StoreBuilder, который решает, куда лягут файлы и какой движок их держит.
Store — Clone, и клон здесь ещё один хендл на тот же открытый store, а не
второй store.
Обычно приложение открывает один и держит его всю жизнь процесса. Но это не обязано быть так: открытых store может быть несколько — над разными файлами или под разными движками.
По пути, который вы назвали
Заголовок раздела «По пути, который вы назвали»let store = StoreBuilder::new(settings).build()?;build возвращает Store. Держите его, передавайте в new_with, а на выходе
из процесса дайте дропнуться. Во что это обойдётся, если на результат ни разу
не посмотреть, — см. Закрытие.
Или один раз, на весь процесс
Заголовок раздела «Или один раз, на весь процесс»Таскать store во всё, чему он нужен, — способ явный, но утомительный. Store на весь процесс ставят один раз, а достают откуда угодно:
let _ame = "./app.redb".init_global();
let state = NetworkState::new();NetworkState::new() — тот же new_with, только глобальный store подставлен
за вас: структура, объявленная где угодно, соберёт себя, не получив на входе
ничего. У структуры с open = manual никакого new() нет, и программа,
которая уходит от глобального store, получает от компилятора каждый вызов, что
до него ещё тянется, — см.
Как открыть структуру по-своему.
До самого store тоже можно дотянуться:
let store = global_store();store.kv().set("theme", &"dark".to_string())?;Guard и есть store. init_global его и возвращает, и стоит на нём
#[must_use] по причине грубой: guard дропнули — store закрылся, поэтому
"./app.redb".init_global(); отдельной строкой открывает store и на той же
строке закрывает. Свяжите его в main — let _ame = ... — и последние записи
сбросятся, когда main вернётся.
Поставить второй store, пока первый открыт, — паника: в работающем приложении
это баг, а не перенастройка. Когда первый закрыт — guard закрыли или дропнули,
или позвали shutdown(), — его место занимает следующий. На это и опирается
тестовая обвязка, которая открывает store на каждый случай. Взятый раньше
хендл закрытого store так и отвечает Closed, а старый guard, дропнутый
поздно, закрывает только свой store. Обратиться к store, которого ещё никто не
поставил, — тоже паника, и она об этом прямо говорит: все аксессоры структуры,
открытой глобально, ходят через global_store(), так что обычно это чтение
поля выше строки с init_global в main.
Там, где неоткрывшийся store приложение обрабатывает само — предлагает сбросить
файл настроек, плагин сообщает о собственной ошибке установки, —
build_global у билдера возвращает отказ, а не паникует:
let _ame = match StoreBuilder::new("./app.redb").build_global() { Ok(guard) => guard, Err(InitGlobal::Open(why)) => { eprintln!("settings are unavailable: {why}"); return Ok(()); } Err(InitGlobal::AlreadyInstalled) => unreachable!("opened once, in main"),};У каждого способа закончить билдер есть глобальный двойник: build и
build_global, migrate и migrate_global, и те же два после
or_in_memory(). Если открытый store уже стоит, ни один из них ничего не
открывает. Store, открытый как-то иначе, ставится через install_global, а
если на месте открытый store, она возвращает новый обратно.
Проход миграции, поставленный на весь процесс:
let (_ame, report) = StoreBuilder::new("./app.redb").migrate_global()?;
if report.has_drift() { eprintln!("a struct changed without a version bump");}Пусть платформа скажет, где
Заголовок раздела «Пусть платформа скажет, где»Написать путь руками — то, что надо для теста или для инструмента, который
направили на конкретный файл. Приложению, которое ставят на чужую машину, нужно
место, отведённое ему платформой, — его и вычисляет located:
let config = StoreBuilder::located(|at| at.app("my-app", "settings"))?;
let named = StoreBuilder::located(|at| at.app_under(Layout::App, "my-app", "settings"))?;
let portable = StoreBuilder::located(|at| at.beside_the_executable("settings"))?;Предлагают три места:
| как | куда ляжет |
|---|---|
at.app("my-app", "settings") |
каталог конфигурации, который эта платформа держит для приложения |
at.app_under(Layout::App, ..) |
то же самое, но по соглашению, которое вы назвали |
at.beside_the_executable("settings") |
рядом с запущенным бинарником — для установки, которую просто распаковали |
Какому соглашению следует app
Заголовок раздела «Какому соглашению следует app»На вопрос «где приложение держит свои файлы» ответ не один, и Layout их
перечисляет:
| layout | куда кладёт файлы |
|---|---|
Layout::App |
раскладка XDG — на всех платформах, Windows и macOS включительно |
Layout::Native |
туда, куда файлы приложения кладёт сама система; вне Linux это уже не XDG |
Layout::ProjectDirs |
то, что выдаёт крейт directories: XDG на Linux и Windows, Library/Application Support на macOS |
app выбирает Layout::App за вас.
Настольному приложению обычно нужен Layout::Native — туда складывает файлы
весь остальной софт платформы, — а app его не выберет никогда. Просите по
имени. Layout::ProjectDirs берут для приложения, чьи файлы уже лежат там,
куда их кладёт directories: store должен попасть туда же, куда смотрит этот
крейт.
Назовите раскладку, как только выпустили релиз. Соглашения расходятся по
дереву достаточно, чтобы store, записанный по одному, не нашёлся по другому.
app_under как раз и закрепляет, где лежат чьи-то настройки, чтобы обновление
их не потеряло.
Как переехать, если store уже лежит в другом месте
Заголовок раздела «Как переехать, если store уже лежит в другом месте»Замыкание, которое отдают located, зовут один раз, и его ответ — путь. Что оно делает до того, как ответить,
решаете вы: located отдаёт Location и забирает PathBuf, а между этим —
обычный код.
Только переезду одного пути мало. Путь — это то, по чему store открывают, а из чего он состоит, знает движок: расширение, которое он дописал, спутник рядом, копия, которую он держит, пока переписывает файл. Раскладка называет их все, и переезд — перебор по ней:
fn relocate(from: &StoreLayout, to: &StoreLayout) -> std::io::Result<()> { for (old, new) in from.names().iter().zip(to.names()) { if old.exists() { std::fs::rename(old, new)?; } }
Ok(())}names — это все имена, а не все файлы. Копию на время переписывания заводят
в начале и убирают в конце, так что у store, который лежит себе спокойно, её нет,
— потому переезд выше и пропускает то, чего нет, а не спотыкается об это. Две
раскладки одного движка сходятся имя к имени: файл с данными ляжет файлом с
данными, спутник — спутником. present — тот же список, только без пропавших:
им спрашивают, что на диске есть на самом деле.
Саму раскладку по пути — не открывая ничего — отдаёт at.files_at, и старое
место как раз тем и неудобно, что спросить у него некого:
let store = StoreBuilder::located(|at| { let now = at.app(&app, "settings")?; let was = at.files_at(at.app(&app, "settings-legacy")?, backend);
relocate(&was, &at.files_at(&now, backend)).ok();
Ok(now)})?.backend(backend).build()?;Вот и весь ответ на «выпустили под одной раскладкой, хотим другую». Ничего особенного тут нет: перенесите файлы, скопируйте, спросите сперва человека или откройте старое место и оставьте как есть — библиотека не делает ничего из этого и ничего из этого не запрещает. Ей отдают один путь, остальное она соберёт сама.
Сойдётся ли переезд — тоже ваше. Замыкание, которое продолжает отвечать старым путём, оставляет оба места жить вечно. Но это решение, а не недосмотр: оно написано там, где его видно.
beside_the_executable — для переносимой установки, а не для той, которую
ставят: в Program Files и /usr/bin тому, кто запускает программу, писать
не дадут, а на macOS бинарник сидит внутри подписанного бандла. Любое из этого
вылезет наружу отказом открыть store — при запуске, то есть в самый подходящий
момент.
Каким файлом становится имя
Заголовок раздела «Каким файлом становится имя»Если у пути, который вы дали, нет расширения, файл называет движок:
let store = StoreBuilder::new(config_dir.join("settings")) .backend(Backend::Json) .build()?;Так получается settings.json. Назовите расширение сами — его не тронут:
let store = StoreBuilder::new(config_dir.join("settings.conf")) .backend(Backend::Json) .build()?;Получается settings.conf, внутри всё тот же JSON. Правило тут про владение, а
не про формат: имя, написанное вами, — ваше, и .conf, за которым уже следит
другой инструмент, продолжит работать; имя, придуманное библиотекой,
принадлежит тому движку, который в итоге работает. Заметнее всего это, когда
backend зовут уже после того, как задан путь: расширение в этот момент
выводят заново.
Какие движки есть и что каждый пишет: Установка.
Что делать, если значение не читается или ключ исчез
Заголовок раздела «Что делать, если значение не читается или ключ исчез»Два решения, и без них не открыться: на диске лежит то, что не разбирается в объявленный тип, — падать или брать дефолтное значение? Ключ под полем удалили — показывать последнее, что было, или сбрасываться до дефолтного?
По умолчанию открытие отказывает, называя путь, а поле с удалённым ключом продолжает показывать значение, которое держало до удаления. Сказать иначе — здесь:
use amethystate::store::OnUnreadable;
let store = StoreBuilder::new(path) .rules(|r| r.on_unreadable(OnUnreadable::UseDefault)) .build()?;Одно и то же правило пишут в трёх местах: здесь, на store; атрибутом на
структуре; атрибутом на отдельном поле. Побеждает то, что ближе к данным, —
значит, сказанное здесь слабее двух других. Refuse, написанный на структуре,
останется Refuse, что бы ни сказали store: отсюда чужое обещание не отменить,
отсюда его только выдают тем, кто ничего не обещал.
Этим он и безопасен. Скажем, приложение держит в store сгенерированные миниатюры, а рядом — лицензию. Ни одна миниатюра не стоит того, чтобы не запуститься, а лицензия стоит:
#[amethystate(prefix = "thumbnails")]pub struct Thumbnails { #[amestate(default = 0u32)] pub generated: u32,}
#[amethystate(prefix = "licence", on_unreadable = Refuse)]pub struct Licence { #[amestate(default = "".to_string())] pub holder: String,}Открытый с UseDefault, нечитаемый счётчик миниатюр заменяется объявленным
значением, и приложение стартует. Нечитаемая лицензия его всё равно остановит —
Licence сказала это про себя. Один store, один процесс, и решает та структура,
которой было не всё равно.
Без этого значение, которое не декодируется, откажет в открытии, а поле с удалённым ключом продолжит показывать последнее, что держало. Что из этого что и почему: Когда значение не читается.
Что делать, когда не читается сам файл
Заголовок раздела «Что делать, когда не читается сам файл»Выше речь про одно значение внутри документа. Здесь — про документ целиком. Спрашивают об этом дважды, и вопрос это разный.
На открытии
Заголовок раздела «На открытии»Файлы store на месте, но это не store: байты, записанные чужой программой; база, которая не база; документ, который не разбирается. Само по себе открытие в таком случае падает, а файлы остаются ровно как есть — чтобы человек посмотрел.
use amethystate::store::WillNotOpen;
let cache = StoreBuilder::new(path) .when_it_will_not_open(WillNotOpen::StartFresh) .build()?;StartFresh убирает файлы и открывает на их месте пустой store — всё, что
перечисляет StoreLayout::names, вместе с копиями на перезапись: копия того, что
не прочиталось, восстановлением не является. Перед тем как что-то удалить, об
этом говорят на warn; обратно потом ничего не возвращают. Это размен для
store, содержимое которого можно собрать заново: кеш, индекс, что угодно
производное.
Ни тот ни другой ответ не про каталог, который не создаётся, не про файл, который держит кто-то ещё, и не про файл, который этому процессу читать нельзя. И не про базу SQLite, которую другое приложение пометило своей, или ту, что записал более новый SQLite. В таких случаях открытие отказывает, что бы здесь ни стояло: это чьи-то данные, которые прекрасно читаются там, где им место, и убрать их значило бы потерять их только потому, что этот процесс до них не достаёт.
Или работать в памяти
Заголовок раздела «Или работать в памяти»Приложение, которому лучше запуститься без настроек, чем не запуститься вовсе,
ставит or_in_memory() перед build или migrate — за фичей memory:
let (store, persistence) = StoreBuilder::new(path).or_in_memory().build();
if let Persistence::InMemory { because } = &persistence { eprintln!("settings will not be saved this run: {because}");}Там, где открытие отказало бы, этот вызов открывает store в памяти и говорит
почему: файл держит другой процесс, в каталог нельзя писать, файл не читается, а
под migrate — ещё и файл записала более новая версия, и шаги этой версии его не
берут. Store в памяти начинает пустым, поэтому каждая объявленная структура
засевает свои дефолты, и ничего не пишет: файл остаётся как есть, для того, кто
сможет его прочитать. Проход, отказавший новому файлу, шёл в открытии, которое
успело пройти и снова закрылось, а префикс, которому отказали, остался
нетронутым.
Обычный ответ — Persistence::OnDisk. Для store на весь процесс то же самое
делают or_in_memory().build_global() и .migrate_global(), и ошибкой они
отвечают, только если на месте уже стоит открытый store.
Store в памяти можно попросить и прямо — через StoreBuilder::in_memory() или
.backend(Backend::Memory): для теста или для сессии, которой нечего хранить.
Держит он то же, что redb, — по значению на путь, в MessagePack, — поэтому store,
откатившийся в память, принимает все записи, которые принимал на диске. Чего он
не хранит, так это формы документа, которую текстовый движок даёт своему файлу.
На сохранении
Заголовок раздела «На сохранении»Файл текстового store затем и лежит, чтобы его правили руками, — значит, сохранение может застать его недопечатанным. А положить свои пути поверх документа, который не разбирается, оно не может.
let store = StoreBuilder::new(path) .backend( Text::json() .when_it_will_not_read(WhenItWillNotRead::TryAgainFor(Duration::from_secs(30))), ) .build()?;| ответ | что делает сохранение |
|---|---|
TryAgainFor(срок) |
не трогает файл и заходит снова, пока не выйдет срок; после него ведёт себя как SetAside |
SetAside |
переносит файл в <имя>.unreadable и сразу пишет |
Refuse |
не пишет ничего, пока файл испорчен |
Overwrite |
пишет документ целиком, и того, что было в файле, больше нет |
По умолчанию — TryAgainFor(Duration::from_secs(5)): файл, который не читается,
это обычно редактор посреди нажатия, и через мгновение он снова документ. Пока
всё может само собой наладиться, ничего не решают: сохранение отказывает,
дебаунсер заходит снова со своим интервалом, а то, что держит store, ждёт в
памяти. Срок считают от первого сохранения, которое застало файл сломанным, а не
от каждой попытки, и начинают заново, как только файл снова разобрался.
Какой ответ верный, зависит от того, чей это файл. Файл настроек, который у
человека открыт в редакторе, просит Refuse или умолчание: напечатанное там —
его работа. Файл, к которому кроме приложения никто и не собирался прикасаться,
просит Overwrite: нечитаемый файл здесь неисправность, которую надо сровнять, а
не чья-то недописанная правка.
SetAside — середина: ничего не теряется и приложение продолжает работать,
потому что напечатанное осталось на диске под именем <имя>.unreadable. Второй
такой файл заменяет первый: два экземпляра того, что не читается, стоят не
больше одного. Если store открыт через ссылку, в сторону уходит файл, на который
она указывает, а сама ссылка остаётся на месте.
Сохранение, которое ничего не записало, сообщает об этом как любой упавший
сброс: save_now отдаёт ошибку вам, а drop кладёт её в лог.
У redb и SQLite такого вопроса нет вовсе. Их файл — база, а не документ: снаружи в него никто не пишет, и файл, который не читается, отсекают на открытии, а не встречают при сохранении.
Какие миграции выполняются
Заголовок раздела «Какие миграции выполняются»Закончить билдер можно двумя способами, и разница между ними только в этом.
build открывает store и не выполняет ни одного шага: ни тех, что передали в
.migrations(), ни объявленных через #[migrate]. Как выглядят объявления, всё
равно записывают, и о дрейфе всё равно сообщают.
migrate открывает его и выполняет все шаги, а потом возвращает вместе со store
отчёт о том, что сделал. Упавший шаг отказывает в открытии с OpenStore::Migrating, и в
отказе лежит тот же отчёт: store поверх данных, которые не догнали объявления,
отдал бы коду старые данные.
or_in_memory() ставят перед любым из них, а build_global и migrate_global
— те же два для store на весь процесс.
Миграции.
Сброс буфера на диск
Заголовок раздела «Сброс буфера на диск»Дроп store сбрасывает то, что тот держал, поэтому при обычном выходе ничего не пропадает. Только вот лёг ли сброс, дроп не скажет, — а диск, который в этот момент оказался полон, узнать хочется:
store.kv().set("port", &8080u16)?;
if let Err(report) = store.save_now() { eprintln!("settings were not saved: {report:?}");}save_now — тот же сброс, только ошибку он отдаёт вам. Store после этого
работает дальше: дроп, когда до него дойдёт, сбросит ещё раз, а о том, что
найдёт, доложит в лог, а не вам. В этом и вся разница — дропа никто не
слышит.
Закрытие
Заголовок раздела «Закрытие»После save_now store продолжает держать свой файл. close его отдаёт: пишет
то, что в буфере, останавливает фоновый поток и отпускает.
if let Err(report) = store.close() { eprintln!("the last writes were not saved: {report:?}");}Дальше любое чтение, запись, скан и сброс в этот store отвечают Closed —
ReadValue::Closed, WriteValue::Closed, ScanKeys::Closed, Flush::Closed,
и каждый называет то место, о котором спрашивали, — и каждый его клон тоже,
файл у них один на всех. Позвать дважды можно, ничего не случится, и дроп
следом тоже ничего не сделает.
Поле — исключение, и нарочно: get оно отвечает из памяти дальше, а говорит об
этом через try_get, у которого Reason::Closed значит «это последнее, что я
слышало», а не «это упало».
Зовут его тогда, когда файл понадобился кому-то ещё: другому процессу, резервной копии, переименованию. Что именно это даст, зависит от движка — каждый отдаёт то, что держал:
| движок | что занимает живой store | что освобождает закрытие |
|---|---|---|
| sqlite | файл, против всей машины | переименование и удаление начинают работать |
| redb | право его открыть | второй store сможет открыть тот же путь |
| json, toml, ron | ничего между сбросами | только фоновый поток |
Текстовый store — всё равно файл одного процесса. Раз между сбросами он
ничего не держит, второй store на том же файле ничто не остановит, и записанное
обоими в данные встретится в файле: разные значения доедут оба, одно и то же —
от того, кто сохранился последним. Служебный файл рядом так не сливается:
.meta целиком пишет тот, кто писал его последним. Не сливается и миграция: два
store, открывшиеся разом и оба с шагами, копируют в один и тот же .bak, и
одному из них откажут. Отдавайте файл одному процессу за раз и закрывайте store,
прежде чем файл возьмёт другой.
Глобальный store закрывается так же. Его закрывает дроп guard, а ошибка
уходит в tracing::error! под таргетом amethystate — туда, где вызывающий с
ней уже ничего не сделает. Ошибку отдаёт обратно amethystate::shutdown(), и
зовут его до того, как guard выйдет из области видимости. Статик не дропается
никогда, поэтому без одного из двух последний интервал дебаунса пропадёт даже
при чистом возврате.
Даёт это ровно одно: вы узнаёте о провале и можете сказать о нём человеку.
Спасти записи на этом месте уже нельзя — store закрыт, и всё, кроме get из
памяти, отвечает Closed. Повторный close тоже не поможет: он видит, что
закрытие уже началось, и отвечает Ok, ничего не сбрасывая. Место для второй
попытки — save_now, пока store ещё открыт.
Как дождаться диска ради одной записи, а не всех сразу: Durability.