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:
@@ -0,0 +1,131 @@
|
||||
// TRACES: FR-NC-6c
|
||||
//! Virtual-filesystem conventions layered over a directory.
|
||||
//!
|
||||
//! A sync client in virtual-files mode leaves a *placeholder* where a file is
|
||||
//! catalogued but not downloaded. The folder is otherwise ordinary, so all of
|
||||
//! [`FolderBackend`](crate::FolderBackend) applies — only three questions
|
||||
//! differ, and they are the whole of this trait: what is a placeholder, what
|
||||
//! is the photograph really called, and can the content be summoned.
|
||||
//!
|
||||
//! # Why this is not a separate backend
|
||||
//!
|
||||
//! It varies nothing about listing, reading, writing, moving or deleting — a
|
||||
//! second connector would duplicate every one of those to change a name test.
|
||||
//! More decisively, **the interesting capability is not a property of the
|
||||
//! backend at all**: the same folder can materialise on demand while the sync
|
||||
//! client is running and cannot when it is not, so it has to be computed per
|
||||
//! connection either way. Registering a `folder-vfs` provider beside `folder`
|
||||
//! would ask the user to choose between two things that differ by whether a
|
||||
//! background process happens to be up.
|
||||
//!
|
||||
//! # Why the plain case is a `Vfs` too
|
||||
//!
|
||||
//! [`NoVfs`] answers "nothing is a placeholder" and refuses to materialise.
|
||||
//! That keeps one code path through the backend rather than an `Option` tested
|
||||
//! at every call site, and it is the shape a third convention — Dropbox,
|
||||
//! OneDrive, macOS FileProvider — slots into.
|
||||
//!
|
||||
//! # What is *not* abstracted here
|
||||
//!
|
||||
//! Windows and macOS express placeholders in filesystem metadata rather than
|
||||
//! in the name: a reparse point, or `st_blocks == 0` against a non-zero
|
||||
//! `st_size`. That form needs a `Metadata` to answer, not a name, and the one
|
||||
//! convention this project has met needs only a name. Widening the trait for a
|
||||
//! platform nobody has run this on would be guessing at the shape.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::path::Path;
|
||||
|
||||
use dr_sync::RemoteError;
|
||||
|
||||
/// A placeholder convention, and what can be done about it.
|
||||
///
|
||||
/// Implementations are held behind an `Arc` and used from every worker
|
||||
/// thread.
|
||||
pub trait Vfs: Send + Sync {
|
||||
/// A name for logs and the interface. "none", "Nextcloud".
|
||||
fn name(&self) -> &'static str;
|
||||
|
||||
/// Whether a name **on disk** stands for content that is not here.
|
||||
fn is_placeholder(&self, on_disk: &str) -> bool;
|
||||
|
||||
/// The photograph's own name, given whatever is on disk.
|
||||
///
|
||||
/// This is what the catalog records and what identity is derived from, so
|
||||
/// a file keeps one name and one id across being downloaded and released.
|
||||
/// Reporting the on-disk name instead makes hydration look like a delete
|
||||
/// and an add.
|
||||
fn real_name<'a>(&self, on_disk: &'a str) -> &'a str;
|
||||
|
||||
/// What a placeholder for `name` would be called on disk.
|
||||
fn placeholder_name(&self, name: &str) -> Cow<'_, str>;
|
||||
|
||||
/// Whether content can actually be summoned right now.
|
||||
///
|
||||
/// False where the mechanism is absent — the client is not running, the
|
||||
/// platform has no socket — which is an ordinary state and not an error.
|
||||
/// The backend reports [`Materialisation::Placeholders`] rather than
|
||||
/// [`OnDemand`] when this is false.
|
||||
///
|
||||
/// [`Materialisation::Placeholders`]: dr_sync::Materialisation::Placeholders
|
||||
/// [`OnDemand`]: dr_sync::Materialisation::OnDemand
|
||||
fn can_materialise(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Ask for a placeholder's content. Whole-file and slow.
|
||||
fn materialise(&self, _local: &Path) -> Result<(), RemoteError> {
|
||||
Err(RemoteError::Unsupported("this folder has no VFS client"))
|
||||
}
|
||||
|
||||
/// Give the content back, leaving a placeholder.
|
||||
///
|
||||
/// **Must not delete.** In a synced tree a deletion propagates to the
|
||||
/// server and removes the photograph from every device. An implementation
|
||||
/// that cannot dehydrate returns `Unsupported`.
|
||||
fn dematerialise(&self, _local: &Path) -> Result<(), RemoteError> {
|
||||
Err(RemoteError::Unsupported("this folder has no VFS client"))
|
||||
}
|
||||
}
|
||||
|
||||
/// An ordinary directory: every file is what it appears to be.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct NoVfs;
|
||||
|
||||
impl Vfs for NoVfs {
|
||||
fn name(&self) -> &'static str {
|
||||
"none"
|
||||
}
|
||||
fn is_placeholder(&self, _on_disk: &str) -> bool {
|
||||
false
|
||||
}
|
||||
fn real_name<'a>(&self, on_disk: &'a str) -> &'a str {
|
||||
on_disk
|
||||
}
|
||||
fn placeholder_name(&self, name: &str) -> Cow<'_, str> {
|
||||
// Nothing is ever a placeholder here, so the only honest answer is
|
||||
// the name itself — the backend will look for it, not find a second
|
||||
// candidate, and report the file missing.
|
||||
Cow::Owned(name.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_plain_folder_has_no_placeholders_and_cannot_summon_anything() {
|
||||
let v = NoVfs;
|
||||
assert!(
|
||||
!v.is_placeholder("IMG.CR2.nextcloud"),
|
||||
"not this folder's convention"
|
||||
);
|
||||
assert_eq!(v.real_name("IMG.CR2"), "IMG.CR2");
|
||||
assert!(!v.can_materialise());
|
||||
assert!(v.materialise(Path::new("/x")).is_err());
|
||||
// And it must refuse rather than approximate: deleting a file to
|
||||
// "dehydrate" it would remove the photograph.
|
||||
assert!(v.dematerialise(Path::new("/x")).is_err());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user