Do not push a catalog over one we could not read
`sync_catalog` is a read-modify-write over a file another device also
writes: take theirs, merge, push the union. It was shaped
if let Ok(bytes) = backend.get(&RemoteId::Path(target), None).await {
which folds *every* failure into "there is no remote catalog" and carries
straight on to the upload. On a placeholder library the snapshot in
`.darkroom-derived/` is dehydrated like anything else, so the read failed
every time and each sync pushed our catalog over theirs unmerged —
taking the other device's collections and their members with it.
The same shape as the sidecar bug, and the same fix: a read that fails
for anything other than `NotFound` stops the upload and says why. An
unreadable or unopenable snapshot stops it too — "will not parse" is not
"is not there". This is what `NotFound` and `NotMaterialised` being
separate errors is *for*: one means ours is the whole truth, the other
means do not dare.
Shard downloads go through the same fetch-on-demand read. They logged
and skipped before, which on a library the client keeps dehydrated is
every shard, every pass, and a peer's thumbnails and faces silently
never arriving.
And `put` over a placeholder now replaces it rather than refusing.
Refusing was over-cautious of me: derived state lives inside the library
folder, so a folder the client had dehydrated could never be written to
again. An unconditional write replaces the whole file, so there is
nothing in the stub to keep — content first, then the placeholder, since
in a synced tree an absence is a deletion that propagates. `IfMatch`
still refuses, because a stub's validator describes the stub; `IfAbsent`
fails, because the file is there and only its content is not.
This commit is contained in:
@@ -630,19 +630,60 @@ async fn reading_a_placeholder_is_distinguishable_from_a_missing_file() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn writing_over_a_placeholder_is_refused() {
|
||||
// Writing `a.drsc` beside `a.drsc.stub` makes two files for one document
|
||||
// and hands the sync client a conflict it resolves arbitrarily.
|
||||
async fn an_unconditional_write_replaces_a_placeholder() {
|
||||
// Derived state — shards, the catalog snapshot — lives in the library
|
||||
// folder, so the client dehydrates it like anything else. Refusing here
|
||||
// meant sync could never write to a folder it had been away from. The
|
||||
// whole file is being replaced, so there is nothing in the stub to keep.
|
||||
let t = Tmp::new("vfs-write");
|
||||
t.file("a.drsc.stub", &[0u8]);
|
||||
let b = with_stubs(&t);
|
||||
|
||||
b.put(&RemotePath::new("a.drsc"), b"<new/>".to_vec(), None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(std::fs::read(t.0.join("a.drsc")).unwrap(), b"<new/>");
|
||||
// And exactly one file for one document: a leftover stub beside it is a
|
||||
// conflict the client would resolve in favour of whichever it saw last.
|
||||
assert!(!t.0.join("a.drsc.stub").exists(), "placeholder left behind");
|
||||
assert_eq!(
|
||||
names(&b.list(&RemotePath::root(), None).await.unwrap()),
|
||||
vec!["a.drsc"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_conditional_write_over_a_placeholder_asks_for_the_content_first() {
|
||||
// `IfMatch` guards a read-modify-write. A stub's validator describes the
|
||||
// placeholder, not the document, so nothing here can satisfy it — and
|
||||
// quietly writing anyway is how the other device's edits are lost.
|
||||
let t = Tmp::new("vfs-write-cond");
|
||||
t.file("a.drsc.stub", &[0u8]);
|
||||
let b = with_stubs(&t);
|
||||
|
||||
let e = b
|
||||
.put(&RemotePath::new("a.drsc"), b"<new/>".to_vec(), None)
|
||||
.put(
|
||||
&RemotePath::new("a.drsc"),
|
||||
b"<new/>".to_vec(),
|
||||
Some(Precondition::IfMatch(Validator::new("whatever"))),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(e, RemoteError::NotMaterialised(_)), "{e:?}");
|
||||
assert!(!t.0.join("a.drsc").exists(), "no rival file created");
|
||||
assert!(!t.0.join("a.drsc").exists(), "nothing written");
|
||||
|
||||
// And a create-if-absent fails, because the file *is* there — only its
|
||||
// content is elsewhere.
|
||||
let e = b
|
||||
.put(
|
||||
&RemotePath::new("a.drsc"),
|
||||
b"<new/>".to_vec(),
|
||||
Some(Precondition::IfAbsent),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(e, RemoteError::PreconditionFailed), "{e:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
Reference in New Issue
Block a user