Treat a placeholder as the photograph, not as a one-byte file
The folder connector was pointed at a Nextcloud VFS tree and got three things wrong, the first of which loses work. **A dehydrated sidecar read as absent.** `a.drsc` does not exist when the client has dehydrated it — only `a.drsc.nextcloud` does — so `get` missed, `.ok()` swallowed the `NotFound`, and the sidecar writer took that for "there is no sidecar yet" and wrote a fresh document over the existing one. Every edit another device had put there went with it. That function's own doc comment calls this the exact loss the format's unknown-key preservation exists to prevent. **A stub was catalogued as a 1-byte image**, and ARCH §9.0 measured this machine at 121,785 placeholders against 10,267 real files — so a folder library on a synced tree was ~92% broken rows. **Identity changed on hydration**, so downloading a photograph looked like a delete and an add, orphaning its thumbnail and its face rows. Entries now carry the photograph's own name and a `materialised` flag; `get` on a stub returns the new `RemoteError::NotMaterialised`, which is distinct from `NotFound` precisely because the sidecar writer must treat them differently — it fetches the sidecar and merges, or leaves the entry queued. Hydration is a **borrow**. `BorrowPool` records what was on disk before it asked, so `release_all` dehydrates only what a pass brought and leaves what the user already had. Reference counted: the thumbnail pass and the face pass meet on the same RAW, and without counting the first to finish dehydrates the file the second is reading. A borrow against a plain folder or a server does nothing, so a pass written for VFS runs everywhere. Releasing means asking the client to dehydrate and never deleting: a deletion inside a synced tree propagates to the server and removes the photograph from every device. Not a second backend — the capability is per *connection*, not per type, since the same folder hydrates only while the client runs. The convention arrives through a detector the registry supplies, so `dr-sync-folder` still knows nothing about any client's protocol. ARCH §9.0a records this as an amendment: finding 3 rejected hydration because it costs 100× a range read, and that comparison assumed a connector was available. A folder library has none.
This commit is contained in:
+31
-2
@@ -25,7 +25,7 @@ use std::path::PathBuf;
|
||||
use std::sync::mpsc::{Receiver, Sender};
|
||||
|
||||
use dr_catalog::{Catalog, JobKind, Priority};
|
||||
use dr_sync::{Account, Connection, RemoteBackend, RemoteId, RemotePath};
|
||||
use dr_sync::{Account, Connection, RemoteBackend, RemoteError, RemoteId, RemotePath};
|
||||
use dr_thumbs::ThumbStore;
|
||||
|
||||
use crate::sidecar_cache::SidecarCache;
|
||||
@@ -944,7 +944,35 @@ async fn drain_one(
|
||||
|
||||
let path = RemotePath::new(path_str.to_string());
|
||||
let id = RemoteId::Path(path.clone());
|
||||
let remote = backend.get(&id, None).await.ok();
|
||||
|
||||
// TRACES: FR-NC-6c
|
||||
// A miss and a placeholder are not the same answer, and conflating them
|
||||
// destroys work. This read decides whether the sidecar already on the
|
||||
// remote is merged in; treating "the content is not on this device" as
|
||||
// "there is no sidecar" writes a fresh document over an existing one and
|
||||
// discards every edit another device put there — the exact loss the
|
||||
// format's unknown-key preservation exists to prevent.
|
||||
//
|
||||
// A sidecar is a few kilobytes, so the right response to a placeholder is
|
||||
// to fetch it, not to give up. Where that is impossible — no client
|
||||
// running — the entry stays queued, which is what the outbox is for.
|
||||
let remote = match backend.get(&id, None).await {
|
||||
Ok(bytes) => Some(bytes),
|
||||
Err(RemoteError::NotFound(_)) => None,
|
||||
Err(RemoteError::NotMaterialised(_)) => {
|
||||
backend
|
||||
.materialise(&id)
|
||||
.await
|
||||
.map_err(|e| format!("sidecar is not on this device ({e})"))?;
|
||||
match backend.get(&id, None).await {
|
||||
Ok(bytes) => Some(bytes),
|
||||
Err(e) => return Err(format!("sidecar could not be read ({e})")),
|
||||
}
|
||||
}
|
||||
// Anything else — a refused read, a dead connection — leaves the entry
|
||||
// queued rather than resolved by overwriting.
|
||||
Err(e) => return Err(format!("sidecar could not be read ({e})")),
|
||||
};
|
||||
|
||||
if let Some(bytes) = remote.as_deref() {
|
||||
if !bytes.is_empty() {
|
||||
@@ -5440,6 +5468,7 @@ mod tests {
|
||||
size,
|
||||
modified: None,
|
||||
has_preview: false,
|
||||
materialised: true,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user