Repository navigation
Conversation
4ba69d3 to
007c1d2
Compare
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #2258 +/- ##
==========================================
+ Coverage 78.36% 78.65% +0.28%
==========================================
Files 30 30
Lines 5945 6001 +56
Branches 281 286 +5
==========================================
+ Hits 4659 4720 +61
+ Misses 1210 1207 -3
+ Partials 76 74 -2
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
28a4592 to
1bde283
Compare
|
Breaking compatibility with older bincode-encoded stores without supplying a conversion tool leaves users stranded. Regardless of whether file_store is intended for dev environments, dropping support without providing a migration path or prior notice inevitably leads to silent data loss. |
|
@chukwudiikeh There is no data loss. Everything to persist in |
evanlinjin
left a comment
There was a problem hiding this comment.
I haven't reviewed the tests yet.
EntryIter changes are well written and very thorough - handling all situations elegantly and returning useful errors.
I disagree with the Store::append changes as a corrupted tail becomes unrecoverable.
| let mut payload = Vec::new(); | ||
| // Reserve exactly `len` bytes up front. Fail fast on a corrupt, oversized length prefix. | ||
| // Avoids unnecessary reads and allocations. | ||
| let alloc_failed = usize::try_from(len) | ||
| .map_err(|_| ()) | ||
| .and_then(|len| payload.try_reserve_exact(len).map_err(|_| ())) | ||
| .is_err(); |
There was a problem hiding this comment.
Is there no better way to write this? 😅
There was a problem hiding this comment.
Could we use postcard::from_io instead?
There was a problem hiding this comment.
A better way to write it:
let alloc_failed = match usize::try_from(len) {
Ok(len) => payload.try_reserve_exact(len).is_err(),
Err(_) => true,
};postcard::from_io would require a buffer allocation that must account for the largest possible decodable value. That's impossible to know without looking at the first varint. We could use a large enough scratch buffer, but I prefer the other way that just creates the vector, without the size considerations.
Also, from_io, maps all io::Errors to DeserializeUnexpectedEnd, making the distinction between torn data of allocation failure impossible, and I would like to keep it.
| // Always write at the current end of the file. This handle's cursor may be stale if | ||
| // another handle has appended since we last read, and writing at a stale offset would | ||
| // overwrite those changesets. |
There was a problem hiding this comment.
I don't think this is the behavior we want. If we failed to read the last entry due to DeserializeUnexpectedRead, then further writes will also be corrupted and unreadable due to the len_prefixes being unaligned.
There was a problem hiding this comment.
I didn't have that present while working on this, but what I have in mind was to not leave space for intermediate faulty states, except for concurrency (that as far as I know is not supported nor intended to do so), and bit flipping (which should be remediated by redundancy, i.e., backups).
There is a flaw in the current implementation though, because the file end setting can fail too, leaving the torn bytes behind. What if I fail loudly in that case? So the user knows the append failed and there are torn bytes at the end, and he should retry the append of fix his changesets and retry later.
In this case all intermediate faulty states (without considering concurrency and bit flipping) will be covered.
There was a problem hiding this comment.
I think we need to assume 1 handle per file and use a lockfile to enforce it. I propose reverting to the old logic in master; append should only append after valid data.
One thing that is missing in master, is actually rolling back the data (as you have done with File::set_len).
There was a problem hiding this comment.
I was trying to not get into locks, but as it seems we are headed in that direction, I can roll back the stale head change, and document the expectations in this PR, and leave the lock changes for another PR.
The version on |
This crate is explicitly for testing only, per the README. |
Yes, this is a left over from an early iteration that I never published. |
`bincode` is no longer maintained. `postcard` is the closest maintained project with >52M downloads on crates.io, regular releases, and activity on its repo. BREAKING CHANGE: - Magic Bytes should be changed to avoid accidentaly modifying old file store blobs. - The internal encoding has changed as a result of using postcard. Old file stores won't be recoverable using the latest file_store version. - As this is a development environment store, we don't provide migration utilities. - `StoreError::Bincode` has been renamed to `StoreError::Decode`, and now contains `postcard::Error`s - From now on, trailing bytes after decoding are rejected. - `append` always tries to attach changesets to the latest valid end of the file.
1bde283 to
eb5f3ea
Compare
| #[test] | ||
| fn append_from_stale_handle_does_not_overwrite_existing_changeset() { | ||
| let temp_dir = tempfile::tempdir().unwrap(); | ||
| let file_path = temp_dir.path().join("db_file"); | ||
| let initial = TestChangeSet::from(["initial".to_string()]); | ||
| let first_update = TestChangeSet::from(["first".to_string()]); | ||
| let second_update = TestChangeSet::from(["other".to_string()]); | ||
|
|
||
| let mut first = | ||
| Store::<TestChangeSet>::create(&TEST_MAGIC_BYTES, &file_path).expect("must create"); | ||
| first.append(&initial).expect("must append initial state"); | ||
|
|
||
| let (mut stale, _) = Store::<TestChangeSet>::load(&TEST_MAGIC_BYTES, &file_path) | ||
| .expect("must open second handle"); | ||
|
|
||
| first | ||
| .append(&first_update) | ||
| .expect("must append first update"); | ||
| stale | ||
| .append(&second_update) | ||
| .expect("must append from second handle"); | ||
| drop(first); | ||
| drop(stale); | ||
|
|
||
| let (_, recovered) = Store::<TestChangeSet>::load(&TEST_MAGIC_BYTES, &file_path) | ||
| .expect("both appends must remain decodable"); | ||
| let mut expected = initial; | ||
| expected.extend(first_update); | ||
| expected.extend(second_update); | ||
| assert_eq!( | ||
| recovered, | ||
| Some(expected), | ||
| "a stale handle overwrote an append" | ||
| ); | ||
| } |
There was a problem hiding this comment.
Can we just assume that there is always only one handle at one time per file? As you mentioned, we don't need to support concurrent access. So the only time this would occur is either a bug in the caller's code, or the user of the wallet tries to open the same instance of the wallet twice. I think this should be handled with a lock file (separate PR imo).
There was a problem hiding this comment.
Yes, as I said above, I was trying to not get into locks. I will document the expectations, and leave this out of this PR.
|
I ran the reproduction from #2306, plus variants, against head eb5f3ea (AI-assisted testing). All of these return
The existing test at store.rs:626 also fails with Minor, no change requested: I can send a one-test patch asserting |
`bincode` is deprecated. Replace it with `postcard` and frame each entry as a `u64` varint length prefix followed by the `postcard`-encoded changeset. The length prefix makes the end of an entry explicit instead of relying on the decoder hitting `UnexpectedEof`. This also fixes `Store::load` never terminating when a changeset type encodes to zero bytes (e.g. `()`): every entry now occupies at least the length prefix, so the file offset always advances. A clean end-of-file is detected by peeking the buffer before reading an entry. The `Ok`-arm progress check from the previous commit is superseded by this and is removed. Add regression tests for zero-width changesets. The entry_iter, lib and Cargo.toml changes are taken from bitcoindevkit#2258. BREAKING CHANGE: the on-disk format changes, so existing store files written with `bincode` cannot be loaded. `StoreError::Bincode` is replaced by `StoreError::Decode(postcard::Error)`. Fixes bitcoindevkit#2307 Co-authored-by: nymius <155548262+nymius@users.noreply.github.com> Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
`bincode` is deprecated. Migrate the file store's on-disk encoding to `postcard`, as part of the fix for bitcoindevkit#2308 (see also bitcoindevkit#2258). Each entry is now stored as a `postcard` `u64` varint length prefix followed by the `postcard`-encoded changeset. The length prefix frames entries, so a torn or corrupt entry can be told apart from a clean end of file, and a corrupt, huge length cannot trigger a huge allocation. Bytes left in a frame after decoding are rejected. Real I/O errors are reported as `StoreError::Io`, which `Store::append` now returns when scanning past entries written by another handle. BREAKING CHANGE: - The on-disk format changed. Files written by earlier versions cannot be read; use new magic bytes so old files fail with `StoreError::InvalidMagicBytes`. - `StoreError::Bincode(bincode::ErrorKind)` is replaced by `StoreError::Decode(postcard::Error)`. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…oindevkit#2258 Clarify that this fix for the zero-width changeset hang is independent of the bincode -> postcard migration tracked in bitcoindevkit#2258. If bitcoindevkit#2258 lands first this check becomes moot, but the regression test is still worth keeping.
`()` decodes at end of file as well as before trailing bytes, so a store holding no entries at all hung on the same offset check. Cover it, and assert the loaded changeset rather than only that `load` returned. Also correct the comment above the check. It claimed the check becomes unnecessary once the postcard migration in bitcoindevkit#2258 lands. It does not: a collection of zero-sized elements with a corrupt length prefix loops inside the decoder and never reaches this point, and postcard has that same hole, so neither this check nor a byte-counting size limit catches it. Say what the check actually covers instead.
bincodeis no longer maintained.postcardis the closest maintained project with >52M downloads on crates.io, regular releases, and activity on its repo.BREAKING CHANGE:
StoreError::Bincodehas been renamed toStoreError::Decode, and now containspostcard::Errorsappendalways tries to attach changesets to the latest valid end of the file.These changes were LLM assisted.
Changelog notice
Changed
bincode(unmaintained) withpostcardfor on-disk (de)serialization.version; callers should bump their magic bytes so old files fail fast with
StoreError::InvalidMagicBytesinstead of a decode error.StoreError::Bincode(bincode::ErrorKind)is replaced byStoreError::Decode(postcard::Error).postcardvarint (1-10 bytes). Covered by the same on-disk format change andmagic-byte-bump guidance above.
Fixed
Store::appendnow seeks to the end of the file before writing, so appending through astale handle no longer overwrites changesets written via another handle. A failed append
also truncates the partial frame, leaving the file unchanged.
Checklists
All Submissions:
New Features:
Bugfixes: