Recover file store previous state at open#1632
Recover file store previous state at open#1632nymius wants to merge 3 commits intobitcoindevkit:masterfrom
Conversation
StoreError is an ergonomic way to join all the errors that may occur while opening a changeset datafile. It implements the following variants: - EntryIter: any error related to the decoding of the changesets of the file. - Io: errors related with file management internals. - InvalidMagicBytes: error caused by mismatching with the expected magic bytes.
This new method ensures the integrity of the changesets while opening the file storing them and also emplaces the file pointer at the EOF to avoid overwriting any changeset and start appending new changesets right away.
The following tests have been added: - reopen_recovers_state_after_last_write: check Store::reopen recovers the changeset stored previously in the file. - fail_to_reopen_if_write_is_short: check Store::reopen reads correct changesets and fails to read failing one, retrieving the needed data with StoreError::EntryIter to recover the underlying file to a working state. - reopen_fails_to_read_if_invalid_bytes: check Store::reopen recognizes garbage data as well as Store::open.
| /// error variant will be returned, with the index of the failing changeset and the error it | ||
| /// caused. | ||
| /// | ||
| /// [`create_new`]: Store::create_new |
There was a problem hiding this comment.
I'm probably missing some background but what's the point of doing this unless you return the data you read?
I think a PR here makes sense because we got rid of the "load" system we had before. Maybe this needs a big re-think including deleting lots of the API here. We probably just want append_changeset and create and load as the API. Does anyone need to actually iter changesets?
There was a problem hiding this comment.
I agree that the FileStore has APIs that users likely don't need.
IMO, implementing these api's in the following fashion could make FileStore much simpler.
Key APIs:
-
append_changeset:
We should ensure that duplicate changesets are not stored. Currently, users must callaggregate_changesetsto remove duplicates, but the store isn't updated with the final unique changeset. Instead, we can makeappend_changesetautomatically append the given changeset, aggregate them to remove duplicates, and then store the final aggregated changeset back into theFileStore. -
load:
As suggested by @ValuedMammal, it makes sense to open theFileStoreinappendmode rather thanwritemode. This will place the file pointer at the end of the file, avoiding overwriting and making it easier to append new changesets.
Other Considerations:
-
aggregate_changesets:
While I'm not certain if downstream users will need this API, it seems valuable to keep it exposed for now. -
Iteration Over Changesets:
From a user's perspective, iterating changesets is unnecessary. Currently, it's only used to collect changesets for aggregation. We could either make it private or move the iteration logic directly intoaggregate_changesets.
By handling changeset duplication within append_changeset, we can eliminate the need for users to manually aggregate changesets or iterate over them..
There was a problem hiding this comment.
I agree with your vision.
In this PR I proposed a solution of compromise guided by the principle of stabilizing the API rather than breaking it. I don't know if that's currently something BDK wants to do.
As suggested by @ValuedMammal, it makes sense to open the
FileStoreinappendmode rather thanwritemode. This will place the file pointer at the end of the file, avoiding overwriting and making it easier to append new changesets.
For example, I discarded the append solution because there seemed to be an expected behavior hidden behind the interaction of two functions:
opennot moving the file pointer to the EOF when accessing the file and,append_changesetsetting the file pointer (and length) to the end of the last changeset written.
The interaction of both is what made the append_changeset_truncates_invalid_bytes test to pass in the original code, because 1 leaves the file pointer at the end of the MAGIC_BYTES and 2 removes the invalid bytes completely by shorting the file to the end of the last written changeset.
I'm probably missing some background but what's the point of doing this unless you return the data you read?
That's also why I don't provide the user with the well encoded changesets on error, as that would have implied a more complex structure, using generics (to consider the changeset).
I traded it with the minimum data to locate and isolate the failure in the file.
You may find a clearer idea of this in the fail_to_reopen_if_write_is_short test, at this line.
If there is a common agreement to make the refactoring above instead of trying to fix the current API I can close this PR and try to implement the changes in another one.
There was a problem hiding this comment.
I don't understand why we want to ensure duplicate changesets are not stored. They do no harm and only happen in the case of some kind of programmer error.
There's no issue with stabalizing the API of this crate I think. We can do what we want with it. To me at least, this reopen makes it make even less sense because it reads in every entry but doesn't return it which is a waste.
I agree with @nymius that opening the file with append is a nice idea in theory but it's actually counter productive here. There's no magic to opening files with append it just makes the operating system handle what we can do better.
I vote for a PR cutting the API down to:
create-- renamecreate_new.load-- does whatopendoes and aggregates the changeset and leaves the file pointer at the right spot. Errors if the file didn't exist or wrong magic bytes.append-- does whatappend_changesetdoescreate_or_load-- does whatopen_or_create_newdoes
Then updating the README to explain that this is the simplest implementation of a bdk compatible database. It's useful for development but should probably not be used beyond that. No effort has been made to make it backwards compatible so you have to do that yourself. Link them to docs on sqlite feature for what they should use in production.
There was a problem hiding this comment.
I think load should essentially return Result<(C, Self), Error> so that we can get rid of .aggregate_changeset. This forces the caller to only read once.
There was a problem hiding this comment.
The interaction of both is what made the append_changeset_truncates_invalid_bytes test to pass in the original code, because 1 leaves the file pointer at the end of the MAGIC_BYTES and 2 removes the invalid bytes completely by shorting the file to the end of the last written changeset.
Got the point.
I don't understand why we want to ensure duplicate changesets are not stored.
On ensuring this -> we can get rid of iterating over the changeset each time user want to get the total changeset.
Instead, we can make append_changeset automatically append the given changeset, aggregate them to remove duplicates, and then store the final aggregated changeset back into the FileStore
Though in my proposed impl -> we have to iterate at the end -> so doesn't make much diference.
They do no harm and only happen in the case of some kind of programmer error.
Though I have no idea regarding this but if that's the case -> then it does not make sense to try to keep unique set of changesets.
I think load should essentially return Result<(C, Self), Error> so that we can get rid of .aggregate_changeset. This forces the caller to only read once.
Combining both the load/open and aggregate_changeset logic does make sense because IMO, both are correlated to each other as in order to load a wallet , a user has to get the aggregated changeset for which they have to load the store instance.
By this, we don't require to create another public api and users don't have to manually call a function to get the required changeset for loading wallet.
There was a problem hiding this comment.
On ensuring this -> we can get rid of iterating over the changeset each time user want to get the total changeset.
If loading gets too slow, we can just have a method to merge changesets. I.e. overwrite the file with just one entry, the new aggregate changeset. Or aggregate x number of changesets at the end.
It does NOT make sense to have a check that stops duplicate changesets being stored. How are we going to implement this? Have an aggregate changeset of all persisted changes in memory that ensures the new changeset is not a subset of it? That typically doesn't happen, and if it does, it means that there is a problem with the in-memory data structures. The in-memory data structures should only output a changeset because there are changes to it.
There was a problem hiding this comment.
I think
loadshould essentially returnResult<(C, Self), Error>so that we can get rid of.aggregate_changeset. This forces the caller to only read once.
I have found an issue with this approach. The (C, Self) return type is hard to adapt to the WalletPersister::initialize trait.
By the documentation I don't have available any file path to load the store from and I must return all data stored in the persister (i.e. return the AggregatedChangeset):
- The database schema of the
persister(if any), should be initialized...- The implementation must return all data currently stored in the
persister. If there is no data, return an empty changeset (using [ChangeSet::default()]).- some persister implementations may NOT require initialization at all (and not error).
/// [`persist`]: WalletPersister::persist
fn initialize(persister: &mut Self) -> Result<ChangeSet, Self::Error>;
file_store's trait implementation currently in master assumes persister (i.e. file_store::Store) has been opened or loaded before reaching the trait method (look how aggregate_changesets() is called without create_new/open ceremony).
#[cfg(feature = "file_store")]
impl WalletPersister for bdk_file_store::Store<ChangeSet> {
type Error = FileStoreError;
fn initialize(persister: &mut Self) -> Result<ChangeSet, Self::Error> {
persister
.aggregate_changesets()
.map(Option::unwrap_or_default)
.map_err(FileStoreError::Load)
}
Summing up, the constraints for file_store::Store are:
- Must return aggregated changeset.
- Cannot load from file at
WalletPersister::initializebecausefile_pathis not at scope at that moment - including a
file_pathparameter inWalletPersister::initializewill break API and force changes onrusqliteimplementation.
Following LLForun's idea, I propose the following new API for file_store::Store:
create: rename ofcreate_newload: open file, place file pointer at EOF and returnfile_store::Store.dump: aggregate changesets and return the aggregated changeset.append: rename ofappend_changesetload_or_create:Store::createorStore::load.
There was a problem hiding this comment.
Working on this I realized we can have both, (C, Store) as return type of Store::load and a Store::dump method to call on <bdk_file_store::Store<ChangeSet> as WalletPersister>::initialize, so I will keep both.
|
Overrode by #1684 |
54251a7 docs(file_store): Show how to overwrite original file during recovery (志宇) 52f2061 refactor(store)!: change Store's method and error names (nymius) fc76d66 feat(store): add `Bincode` error variant to FileError enum (nymius) 3998788 fix(store): replace `Path.exists` by `OpenOptions.create_new` (nymius) Pull request description: ### Description The `Store::open` method doesn't recovers the previous `Store` state saved in the file and emplaces the file pointer just after the magic bytes prefix, this later is agravated by `Store::append_changeset` which sets the file pointer after the last written changeset. The combination of both causes the lost of any previous changeset there may have been in the file. Is natural to think this shouldn't be the expected behavior, as @KnowWhoami pointed out in #1517, and the `Store` should recover the previous changesets stored in the file store. The approach proposed in #1632 triggered a discussion about more changes in `Store`, which motivated the current refactor. Besides the fix for #1517, the following methods have been changed/renamed/repurposed in Store: - `create`: create file and retrieve store, if exists fail. - `load`: load changesets from file, aggregate them and return aggregated changeset and `Store`. If there are problems with decoding, fail. - `dump`: aggregate all changesets and return them. - `load_or_create`: try load or fallback to create. Fixes #1517. Overrides #1632. ### Notes to the reviewers **Load** return type is `Result<(Option<C>, Store), StoreErrorWithDump>` to allow methods which doesn't use `WalletPersister` to get the aggregated changeset right away. **Dump** is kept to allow `WalletPersister::initialize` method to retrieve the aggregated changeset without forcing the inclusion of the additional parameters needed by `store::load` to the trait, which would also affect the `rusqlite` implementation. ### Changelog notice #### Added: - `StoreError` enum, which includes `Io`, `Bincode` and `InvalidMagicBytes`. - "not intended for production" notice in general `README` for `file store`. #### Changed: - `Store::create_new` to `Store::create`, with new return type: `Result<Self, StoreError>` - `Store::open` to `Store::load`, with new return type: `Result<(Self, Option<C>), StoreErrorWithDump<C>>` - `Store::open_or_create` to `Store::load_or_create`, with new return type: `Result<(Option<C>, Self), StoreErrorWithDump<C>> ` - `Store::aggregate_changesets` to `Store::dump`, with new return type: `Result<Option<C>, StoreErrorWithDump<C>>` - `FileError` to `StoreError` - `AggregateChangesetsError` to `StoreErrorWithDump`, which now can include all the variants of `StoreError` in the error field. #### Deleted: - `IterError` deleted. <!-- Notice the release manager should include in the release tag message changelog --> <!-- See https://keepachangelog.com/en/1.0.0/ for examples --> ### Checklists #### All Submissions: * ~~I've signed all my commits~~ * ~~I followed the [contribution guidelines](https://github.com/bitcoindevkit/bdk/blob/master/CONTRIBUTING.md)~~ * ~~I ran `cargo fmt` and `cargo clippy` before committing~~ #### New Features: * ~~I've added tests for the new feature~~ * ~~I've added docs for the new feature~~ #### Bugfixes: * ~~This pull request breaks the existing API~~ * ~~I've added tests to reproduce the issue which are now passing~~ * ~~I'm linking the issue being fixed by this PR~~ ACKs for top commit: evanlinjin: ACK 54251a7 Tree-SHA512: d41dee149af7d0c2eba4f0b84b360eb2aad2b5f3df2d3160de285370e637619c25156678ee84a12295c7d2410704182819f6c5c612f68f81556747ad7dd0eb17
Description
The
Store::openmethod doesn't recovers the previousStorestate saved in the file and emplaces the file pointer just after the magic bytes prefix, this later is agravated byStore::append_changesetwhich sets the file pointer after the last written changeset. The combination of both causes the lost of any previous changeset there may have been in the file.Is natural to think this shouldn't be the expected behavior, as @KnowWhoami pointed out in #1517, and the
Storeshould recover the previous changesets stored in the file store.To avoid producing breaking changes the expected logic has been implemented in a new
Store::reopenmethod, which opens the file, iterates the changesets and returns a newStorewith the file pointer correctly placed at the end of the already existing changesets. As this method checks the integrity of the stored changesets while iterating them, if any error is found, the method will return aStoreError::EntryItererror with the index of the offending changeset, the error caused by it and the number of bytes read when the error happened.The
StoreErroris a new error enum including the following variants:EntryIter: any error related to the decoding of the changesets of the file.Io: errors related with file management internals.InvalidMagicBytes: error caused by mismatching with the expected magic bytes.StoreError::IoandStoreError::InvalidMagicBytesare the same thanFileError::IoandFileError::InvalidMagicBytes. My idea is to proposeStoreErroras a replacement ofFileErrorin the future. It has been implemented in this way to avoid the addition of extra nested variants at the moment of matching the error.Fixes #1517.
Notes to the reviewers
I have committed the changes as
featand notfixbecause, although this PR fixes #1517, the changes do not imply a change in the currentStoremethods, but the addition of new one and a newErrorenum.Changelog notice
Store::reopenmethod infile_store.StoreErrorenum infile_store.Checklists
All Submissions:
cargo fmtandcargo clippybefore committingNew Features:
Bugfixes: