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:
@@ -38,6 +38,50 @@ pub struct ChunkConstraints {
|
||||
pub max_chunks: u32,
|
||||
}
|
||||
|
||||
/// TRACES: FR-NC-6c
|
||||
/// Whether every listed object's content is actually reachable.
|
||||
///
|
||||
/// Every backend but a virtual-filesystem folder answers [`Always`](Self::Always).
|
||||
/// A VFS folder is the case this exists for: the sync client leaves a
|
||||
/// placeholder where a file is catalogued but not downloaded, so the name is
|
||||
/// listable and the bytes are not (ARCH §9.0).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Materialisation {
|
||||
/// Listing an object means its content can be read. Every server backend,
|
||||
/// and a plain directory.
|
||||
Always,
|
||||
|
||||
/// Some objects are placeholders, and nothing this process can do will
|
||||
/// change that — the sync client is not running, or the platform offers no
|
||||
/// way to ask. Such an object reads as
|
||||
/// [`RemoteError::NotMaterialised`](crate::RemoteError::NotMaterialised)
|
||||
/// and is shown as offline rather than broken.
|
||||
Placeholders,
|
||||
|
||||
/// Some objects are placeholders, and this backend can ask for their
|
||||
/// content — and give it back.
|
||||
///
|
||||
/// **Whole-file, and that is the whole difficulty.** Hydration has two
|
||||
/// states, one byte or all bytes, so using it to fill a grid transfers the
|
||||
/// entire library to produce thumbnails (ARCH §9.0). It belongs to the
|
||||
/// originals tier — an image opened in develop, exported, or deliberately
|
||||
/// pinned — and to passes the user has asked for and been quoted a price
|
||||
/// on. Never to browsing.
|
||||
OnDemand,
|
||||
}
|
||||
|
||||
impl Materialisation {
|
||||
/// Whether content can be fetched on request.
|
||||
pub fn can_materialise(self) -> bool {
|
||||
matches!(self, Materialisation::OnDemand)
|
||||
}
|
||||
|
||||
/// Whether some objects may have no content locally.
|
||||
pub fn has_placeholders(self) -> bool {
|
||||
!matches!(self, Materialisation::Always)
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-NC-3
|
||||
/// Whether the server can render thumbnails, and for what.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -69,6 +113,8 @@ pub struct Capabilities {
|
||||
/// Conditional write (If-Match), for conflict-safe sidecar updates.
|
||||
pub conditional_write: bool,
|
||||
pub server_previews: ServerPreviews,
|
||||
/// Whether a listed object's content is necessarily present.
|
||||
pub materialisation: Materialisation,
|
||||
}
|
||||
|
||||
impl Capabilities {
|
||||
@@ -83,6 +129,9 @@ impl Capabilities {
|
||||
bulk_upload: false,
|
||||
conditional_write: false,
|
||||
server_previews: ServerPreviews::None,
|
||||
// The weakest backend still answers for everything it lists;
|
||||
// placeholders are a property a backend opts into.
|
||||
materialisation: Materialisation::Always,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,22 @@ pub enum RemoteError {
|
||||
#[error("not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
/// TRACES: FR-NC-6c
|
||||
/// The object exists, but its content is not on this device.
|
||||
///
|
||||
/// A virtual-filesystem placeholder: the sync client holds the name and a
|
||||
/// stub, and the bytes are still on the server (ARCH §9.0).
|
||||
///
|
||||
/// **Emphatically not [`NotFound`](Self::NotFound), and the distinction is
|
||||
/// what stops a silent data loss.** The sidecar writer reads before it
|
||||
/// writes, and treats a miss as "there is no sidecar yet, create one" — so
|
||||
/// a dehydrated sidecar reported as absent makes it write a fresh document
|
||||
/// over an existing one, discarding every edit another device had put
|
||||
/// there. It is also the difference between an error a user can act on
|
||||
/// (fetch it) and one they cannot (it is gone).
|
||||
#[error("not on this device: {0}")]
|
||||
NotMaterialised(String),
|
||||
|
||||
/// The backend does not support this operation. Expected, not a bug —
|
||||
/// callers check capabilities and adapt.
|
||||
#[error("operation unsupported by this backend: {0}")]
|
||||
|
||||
+46
-1
@@ -35,7 +35,9 @@ pub mod types;
|
||||
pub mod upload;
|
||||
|
||||
pub use account::{Account, AccountError, AccountStore, Connection, Secret, LEGACY_BACKEND};
|
||||
pub use capability::{Capabilities, ChangeDetection, ChunkConstraints, ServerPreviews};
|
||||
pub use capability::{
|
||||
Capabilities, ChangeDetection, ChunkConstraints, Materialisation, ServerPreviews,
|
||||
};
|
||||
pub use error::RemoteError;
|
||||
pub use provider::{BackendProvider, BackendRegistry, SignIn};
|
||||
pub use reachability::{Connectivity, Reachability};
|
||||
@@ -151,6 +153,49 @@ pub trait RemoteBackend: Send + Sync {
|
||||
/// destination, not to claim they created it.
|
||||
async fn create_dir(&self, path: &RemotePath) -> Result<(), RemoteError>;
|
||||
|
||||
// ---- materialisation --------------------------------------------------
|
||||
|
||||
/// TRACES: FR-NC-6c
|
||||
/// Ask for a placeholder's content to be brought to this device.
|
||||
///
|
||||
/// Only meaningful where [`Capabilities::materialisation`] is
|
||||
/// [`Materialisation::OnDemand`]; others return
|
||||
/// [`RemoteError::Unsupported`].
|
||||
///
|
||||
/// **Whole-file, and slow.** There is no partial hydration: a placeholder
|
||||
/// becomes one byte or all of them, so this transfers a 27 MB RAW to
|
||||
/// answer a question a 256 KB range read would have answered (ARCH §9.0
|
||||
/// finding 3). It is for the originals tier — develop, export, a pin the
|
||||
/// user asked for — and for passes the user has been quoted a price on and
|
||||
/// agreed to. **Never for filling a grid**: doing so downloads the entire
|
||||
/// library to produce thumbnails.
|
||||
///
|
||||
/// Returns once the content is readable. Callers that borrowed it should
|
||||
/// hand it back with [`dematerialise`](Self::dematerialise).
|
||||
async fn materialise(&self, _id: &RemoteId) -> Result<(), RemoteError> {
|
||||
Err(RemoteError::Unsupported(
|
||||
"this backend has no placeholders to materialise",
|
||||
))
|
||||
}
|
||||
|
||||
/// Give a placeholder's content back, freeing the disk it held.
|
||||
///
|
||||
/// The counterpart that makes hydration a *borrow* rather than an
|
||||
/// acquisition: a pass that hydrates a library to index it can return each
|
||||
/// file as it finishes, so peak disk is the working set rather than the
|
||||
/// library.
|
||||
///
|
||||
/// **Never destructive.** On a synced folder this asks the client to
|
||||
/// dehydrate; it must not delete, because a deletion in a synced tree
|
||||
/// propagates to the server and removes the photograph everywhere. An
|
||||
/// implementation that cannot dehydrate must return
|
||||
/// [`RemoteError::Unsupported`] rather than approximating it.
|
||||
async fn dematerialise(&self, _id: &RemoteId) -> Result<(), RemoteError> {
|
||||
Err(RemoteError::Unsupported(
|
||||
"this backend has no placeholders to release",
|
||||
))
|
||||
}
|
||||
|
||||
// ---- optional ---------------------------------------------------------
|
||||
|
||||
/// Server-rendered thumbnail, where available.
|
||||
|
||||
@@ -253,6 +253,7 @@ mod tests {
|
||||
size: 0,
|
||||
modified: None,
|
||||
has_preview: false,
|
||||
materialised: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,6 +266,7 @@ mod tests {
|
||||
size: 1000,
|
||||
modified: None,
|
||||
has_preview: false,
|
||||
materialised: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,6 +303,7 @@ mod tests {
|
||||
bulk_upload: false,
|
||||
conditional_write: true,
|
||||
server_previews: ServerPreviews::None,
|
||||
materialisation: crate::Materialisation::Always,
|
||||
},
|
||||
lists: RefCell::new(0),
|
||||
probes: RefCell::new(0),
|
||||
|
||||
@@ -97,6 +97,23 @@ pub struct RemoteEntry {
|
||||
/// Whether the server claims a renderable preview exists. Advisory: stock
|
||||
/// Nextcloud reports none for RAW (ARCH §6.7).
|
||||
pub has_preview: bool,
|
||||
|
||||
/// TRACES: FR-NC-6c
|
||||
/// Whether [`get`](crate::RemoteBackend::get) can produce this object's
|
||||
/// content right now.
|
||||
///
|
||||
/// True for everything a server backend lists — the bytes are remote, but
|
||||
/// they are reachable. False only for a virtual-filesystem placeholder,
|
||||
/// where the name is on this device and the content is not (ARCH §9.0).
|
||||
///
|
||||
/// The catalog maps this to [`Availability::Offline`](dr_types::Availability),
|
||||
/// which is the difference between a photograph shown as *not downloaded*
|
||||
/// and one shown as broken.
|
||||
///
|
||||
/// **`size` is not meaningful when this is false.** A Linux suffix-mode
|
||||
/// stub is one byte and carries no record of what it stands for, so there
|
||||
/// is nothing to report but zero.
|
||||
pub materialised: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
|
||||
@@ -237,6 +237,7 @@ mod tests {
|
||||
size: *size,
|
||||
modified: None,
|
||||
has_preview: false,
|
||||
materialised: true,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user