The passes that need every photograph's bytes — thumbnails, face indexing — now borrow each one and release it at the end. On a placeholder library that is the difference between peak disk being the working set and being the whole library. Including on cancellation, which was nearly missed: the face sweep returns mid-loop when the user presses Stop, and without releasing there the disk is spent and nothing is delivered for it. `materialise` now answers whether *it* fetched the content. The pool used to work that out by listing a file's parent directory — one listing per file across a library — when the backend already had to `stat` it to decide whether to ask. One syscall instead of a directory walk, and it removes the bug class the tests found earlier: a file at the library root has no `parent()`, so every one of them read as already-downloaded. **Pinning is the retention control**, and it drives the model the catalog already had rather than a second one. `tier_desired` is what the user asked to keep hydrated, `pending_pins` is the resumable work list, and a pinned collection is never dehydrated for the same reason it was never evicted. It was in fact *broken* here before: `get` on a stub failed, and the pin worker logged "one unreadable file must not abandon the whole pin" and silently did nothing. Pinned originals on such a library are recorded with `path = NULL` (`Cache::record_in_place`) rather than copied under `originals/`. Two reasons, and the second is the important one. A copy would hold every pinned photograph twice, with the budget able to evict the half that was not costing the disk. And `release` deletes the file a row names — so a row that names none cannot delete anything, which puts the one catastrophic operation out of reach by construction rather than by remembering not to call it. Deleting a materialised file inside a synced tree removes the photograph from the server and every other device. Handing disk back is `spawn_dehydrate`, which asks the client. Two gaps written down rather than papered over (docs/storage.md §7): a hydrating pass cannot yet quote its cost, because a stub reports no size; and the two sweeps hold separate pools, so a library indexed for both fetches twice.
267 lines
8.6 KiB
Rust
267 lines
8.6 KiB
Rust
//! The borrow contract, against a filesystem and a fake client.
|
|
//!
|
|
//! The fake stands in for the sync client's socket, not for the filesystem:
|
|
//! it renames stubs exactly as suffix-mode VFS does, so everything under test
|
|
//! is the real path resolution and the real state tracking.
|
|
|
|
use super::*;
|
|
use crate::{FolderBackend, Vfs};
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
|
|
/// A stand-in for a sync client, counting what it was asked to do.
|
|
struct FakeClient {
|
|
suffix: &'static str,
|
|
hydrations: AtomicUsize,
|
|
dehydrations: AtomicUsize,
|
|
/// When true, refuse to hydrate — the client is running but the server is
|
|
/// not reachable.
|
|
broken: bool,
|
|
}
|
|
|
|
impl FakeClient {
|
|
fn new() -> Arc<Self> {
|
|
Arc::new(Self {
|
|
suffix: ".nextcloud",
|
|
hydrations: AtomicUsize::new(0),
|
|
dehydrations: AtomicUsize::new(0),
|
|
broken: false,
|
|
})
|
|
}
|
|
fn broken() -> Arc<Self> {
|
|
Arc::new(Self {
|
|
suffix: ".nextcloud",
|
|
hydrations: AtomicUsize::new(0),
|
|
dehydrations: AtomicUsize::new(0),
|
|
broken: true,
|
|
})
|
|
}
|
|
}
|
|
|
|
impl Vfs for FakeClient {
|
|
fn name(&self) -> &'static str {
|
|
"fake"
|
|
}
|
|
fn is_placeholder(&self, on_disk: &str) -> bool {
|
|
on_disk.ends_with(self.suffix)
|
|
}
|
|
fn real_name<'a>(&self, on_disk: &'a str) -> &'a str {
|
|
on_disk.strip_suffix(self.suffix).unwrap_or(on_disk)
|
|
}
|
|
fn placeholder_name(&self, name: &str) -> std::borrow::Cow<'_, str> {
|
|
std::borrow::Cow::Owned(format!("{name}{}", self.suffix))
|
|
}
|
|
fn can_materialise(&self) -> bool {
|
|
true
|
|
}
|
|
fn materialise(&self, local: &Path) -> Result<(), RemoteError> {
|
|
self.hydrations.fetch_add(1, Ordering::SeqCst);
|
|
if self.broken {
|
|
return Err(RemoteError::Network("no server".into()));
|
|
}
|
|
// Suffix mode renames rather than filling in place, and writes the
|
|
// real content.
|
|
let real = PathBuf::from(local.to_string_lossy().strip_suffix(self.suffix).unwrap());
|
|
std::fs::write(&real, vec![9u8; 4096]).unwrap();
|
|
std::fs::remove_file(local).unwrap();
|
|
Ok(())
|
|
}
|
|
fn dematerialise(&self, local: &Path) -> Result<(), RemoteError> {
|
|
self.dehydrations.fetch_add(1, Ordering::SeqCst);
|
|
let stub = format!("{}{}", local.display(), self.suffix);
|
|
std::fs::write(&stub, [0u8]).unwrap();
|
|
std::fs::remove_file(local).unwrap();
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
struct Tmp(PathBuf);
|
|
|
|
impl Tmp {
|
|
fn new(name: &str) -> Self {
|
|
let d = std::env::temp_dir().join(format!("dr-borrow-{name}"));
|
|
let _ = std::fs::remove_dir_all(&d);
|
|
std::fs::create_dir_all(&d).unwrap();
|
|
Tmp(d)
|
|
}
|
|
/// A dehydrated photograph.
|
|
fn stub(&self, rel: &str) -> &Self {
|
|
std::fs::write(self.0.join(format!("{rel}.nextcloud")), [0u8]).unwrap();
|
|
self
|
|
}
|
|
/// One the user already has.
|
|
fn real(&self, rel: &str) -> &Self {
|
|
std::fs::write(self.0.join(rel), vec![1u8; 2048]).unwrap();
|
|
self
|
|
}
|
|
fn has(&self, rel: &str) -> bool {
|
|
self.0.join(rel).is_file()
|
|
}
|
|
fn backend(&self, vfs: Arc<dyn Vfs>) -> FolderBackend {
|
|
FolderBackend::with_vfs(&self.0, vfs).unwrap()
|
|
}
|
|
}
|
|
|
|
impl Drop for Tmp {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_borrowed_placeholder_is_downloaded_and_given_back() {
|
|
let t = Tmp::new("cycle");
|
|
t.stub("a.CR2");
|
|
let client = FakeClient::new();
|
|
let b = t.backend(client.clone());
|
|
let pool = BorrowPool::new();
|
|
let path = RemotePath::new("a.CR2");
|
|
|
|
{
|
|
let held = pool.borrow(&b, &path).await.unwrap();
|
|
assert!(held.hydrated(), "this borrow paid for it");
|
|
assert!(t.has("a.CR2"), "content is here while borrowed");
|
|
assert_eq!(
|
|
b.get(&RemoteId::Path(path.clone()), None)
|
|
.await
|
|
.unwrap()
|
|
.len(),
|
|
4096
|
|
);
|
|
}
|
|
|
|
let stats = pool.release_all(&b).await;
|
|
assert_eq!(stats.released, 1);
|
|
assert!(!t.has("a.CR2"), "given back");
|
|
assert!(t.has("a.CR2.nextcloud"), "a placeholder is left behind");
|
|
assert_eq!(client.dehydrations.load(Ordering::SeqCst), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_file_the_user_already_had_is_never_taken_away() {
|
|
// The rule the whole design rests on. Silently undoing a pin — or just a
|
|
// file someone opened yesterday — after an indexing run is the failure
|
|
// that would make people stop trusting this.
|
|
let t = Tmp::new("keep");
|
|
t.real("pinned.CR2");
|
|
let client = FakeClient::new();
|
|
let b = t.backend(client.clone());
|
|
let pool = BorrowPool::new();
|
|
|
|
{
|
|
let held = pool
|
|
.borrow(&b, &RemotePath::new("pinned.CR2"))
|
|
.await
|
|
.unwrap();
|
|
assert!(!held.hydrated(), "nothing was downloaded");
|
|
}
|
|
let stats = pool.release_all(&b).await;
|
|
|
|
assert_eq!(stats.released, 0);
|
|
assert!(t.has("pinned.CR2"), "still here");
|
|
assert_eq!(client.hydrations.load(Ordering::SeqCst), 0);
|
|
assert_eq!(client.dehydrations.load(Ordering::SeqCst), 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn two_lanes_wanting_one_file_download_it_once() {
|
|
// The thumbnail pass and the face pass meet on the same RAW. Without
|
|
// counting, the first to finish dehydrates the file the second is reading.
|
|
let t = Tmp::new("shared");
|
|
t.stub("a.CR2");
|
|
let client = FakeClient::new();
|
|
let b = t.backend(client.clone());
|
|
let pool = BorrowPool::new();
|
|
let path = RemotePath::new("a.CR2");
|
|
|
|
let first = pool.borrow(&b, &path).await.unwrap();
|
|
let second = pool.borrow(&b, &path).await.unwrap();
|
|
|
|
assert_eq!(client.hydrations.load(Ordering::SeqCst), 1, "paid once");
|
|
drop(first);
|
|
assert!(t.has("a.CR2"), "still held by the second borrower");
|
|
drop(second);
|
|
|
|
pool.release_all(&b).await;
|
|
assert!(!t.has("a.CR2"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_failed_download_does_not_leave_a_phantom_borrow() {
|
|
// The client is up but the server is not. The pass must see the failure
|
|
// and the pool must not believe it holds anything.
|
|
let t = Tmp::new("failed");
|
|
t.stub("a.CR2");
|
|
let b = t.backend(FakeClient::broken());
|
|
let pool = BorrowPool::new();
|
|
|
|
let e = pool
|
|
.borrow(&b, &RemotePath::new("a.CR2"))
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(e, RemoteError::Network(_)), "{e:?}");
|
|
assert_eq!(pool.held(), 0);
|
|
assert!(t.has("a.CR2.nextcloud"), "left as it was found");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn borrowing_against_a_plain_folder_does_nothing_at_all() {
|
|
// A caller written for a VFS library must run unchanged elsewhere, or
|
|
// every sweep grows two code paths.
|
|
let t = Tmp::new("plain");
|
|
t.real("a.CR2");
|
|
let b = FolderBackend::new(&t.0).unwrap();
|
|
let pool = BorrowPool::new();
|
|
|
|
let held = pool.borrow(&b, &RemotePath::new("a.CR2")).await.unwrap();
|
|
assert!(!held.hydrated());
|
|
drop(held);
|
|
assert_eq!(pool.release_all(&b).await.released, 0);
|
|
assert!(t.has("a.CR2"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn the_backend_is_what_decides_whether_a_file_was_ours() {
|
|
// Not the pool, and not the caller. The backend had to look before
|
|
// deciding whether to ask, so it can answer for the cost of that same
|
|
// `stat`; a borrower working it out separately would pay a directory
|
|
// listing per file and could get it wrong in the direction that releases
|
|
// a file the user pinned.
|
|
let t = Tmp::new("who-decides");
|
|
t.real("had.CR2").stub("wanted.CR2");
|
|
let b = t.backend(FakeClient::new());
|
|
|
|
assert!(
|
|
!b.materialise(&RemoteId::Path(RemotePath::new("had.CR2")))
|
|
.await
|
|
.unwrap(),
|
|
"already here, so not ours to release"
|
|
);
|
|
assert!(
|
|
b.materialise(&RemoteId::Path(RemotePath::new("wanted.CR2")))
|
|
.await
|
|
.unwrap(),
|
|
"this call fetched it"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_file_borrowed_twice_in_one_pass_is_fetched_once_and_released_once() {
|
|
// Thumbnailing and face indexing visit the same photograph. Fetching it
|
|
// per stage doubles the transfer over the whole library.
|
|
let t = Tmp::new("sequential");
|
|
t.stub("a.CR2");
|
|
let client = FakeClient::new();
|
|
let b = t.backend(client.clone());
|
|
let pool = BorrowPool::new();
|
|
let path = RemotePath::new("a.CR2");
|
|
|
|
// Sequential borrows, as two passes over one work list would make.
|
|
drop(pool.borrow(&b, &path).await.unwrap());
|
|
drop(pool.borrow(&b, &path).await.unwrap());
|
|
|
|
assert_eq!(client.hydrations.load(Ordering::SeqCst), 1, "paid once");
|
|
let stats = pool.release_all(&b).await;
|
|
assert_eq!(stats.released, 1, "given back once");
|
|
}
|