Keep originals on this device, by pin and by use
Fills in `image_cache`, which the previous commit's "On this device" filter read but nothing wrote. Also carries in-flight work that shared these files: the Android TLS root store, the settings page, and a regenerated traceability report. # Two populations, deliberately separate An original is kept here for one of two reasons, and conflating them produces the exact failure the feature exists to prevent. **Pinned** originals were asked for. Pinning a collection before a trip is a promise, so pinned rows are never evicted and never counted against the budget — a cap that could silently delete a pinned trip would make pinning worthless, because it could not be relied on without checking. **Passively cached** originals are a side effect of working: develop already downloads the whole file, so keeping it costs no bandwidth and saves the entire transfer next time. This population is what the budget bounds, evicted least-recently-used, because it otherwise grows until a day of culling fills a disk. Sharing one budget would let a large pin starve the passive cache, or let browsing evict a pin. They are separate. # What was built `dr_catalog::cache` owns the bookkeeping — held tier, size, last use, pinned — and writes the bytes; deciding to download stays with the caller, which is what keeps a crate with no network out of the network's business. Files are written to a temporary and renamed, so a dropped connection cannot leave a truncated file recorded as a complete original. They are named by image id, not filename: `Photos/IMG_0001.CR2` and `Trips/IMG_0001.CR2` are different photographs, and a flat cache keyed on the name would serve one for the other. `spawn_full_fetch` became read-through. A hit is a disk read; a miss stores what it downloads and enforces the budget. A cache that cannot be opened is a miss, not a failure to open the photograph. Pinning writes intent — `tier_desired` — without downloading, so the button responds immediately, and `spawn_pin_fetch` fills it in sequentially afterwards. Sequential because these are tens of megabytes each: the lanes that make the thumbnail sweep fast buy little against one connection's bandwidth and cost a great deal of memory. A pin interrupted by a lost connection resumes from where it stopped. Schema v5 adds `pinned` and `path`. `pinned` is a column rather than something inferred from `pinned_by_rule`, which is ON DELETE SET NULL and so cannot answer for an image whose rule was deleted. A v4 catalog migrates in place; existing rows default to unpinned, the safe direction. The budget and "keep opened originals" come from the settings page rather than a constant, and are applied at startup rather than only on change — a cache capped at 2 GB last session would otherwise spend this one filling to the default. Turning off keeping leaves what is already cached readable: those bytes are paid for, and refusing them would re-download images sitting right there, including pinned ones. Also removes a doubled `#[test]` introduced in the previous commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -810,6 +810,200 @@ impl std::fmt::Display for FetchFailure {
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-NC-6a
|
||||
/// Progress from the pin worker.
|
||||
#[derive(Debug)]
|
||||
pub enum PinMessage {
|
||||
/// How many originals the pin still needs. Sent once, before any transfer.
|
||||
Planned { total: usize },
|
||||
/// One original landed.
|
||||
Stored { done: usize },
|
||||
/// The pin is fully downloaded.
|
||||
Done { stored: usize, bytes: u64 },
|
||||
Failed { message: String, offline: bool },
|
||||
}
|
||||
|
||||
/// TRACES: FR-NC-6a
|
||||
/// Download every original a pin has asked for.
|
||||
///
|
||||
/// Whole files, deliberately: a pin exists so the photographs can be *edited*
|
||||
/// away from the server, and develop needs every photosite. This is the one
|
||||
/// place in the app that fetches originals in bulk, which is why FR-NC-6
|
||||
/// makes it opt-in rather than something sync does on its own.
|
||||
///
|
||||
/// Sequential rather than parallel. The lanes that make the thumbnail sweep
|
||||
/// fast are wrong here: these are tens of megabytes each, so concurrency buys
|
||||
/// little against a single connection's bandwidth and costs a great deal of
|
||||
/// memory — and it is the same contention that produced 423 Locked in the
|
||||
/// sweep.
|
||||
pub fn spawn_pin_fetch(
|
||||
creds: AppCredentials,
|
||||
user_id: String,
|
||||
catalog_path: PathBuf,
|
||||
cache_dir: PathBuf,
|
||||
budget: dr_catalog::Budget,
|
||||
) -> Receiver<PinMessage> {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let (store, catalog) = match (
|
||||
dr_catalog::Cache::open(&cache_dir, budget),
|
||||
Catalog::open(&catalog_path),
|
||||
) {
|
||||
(Ok(s), Ok(c)) => (s, c),
|
||||
(Err(e), _) | (_, Err(e)) => {
|
||||
let _ = tx.send(PinMessage::Failed {
|
||||
message: e.to_string(),
|
||||
offline: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let pending = match store.pending_pins(catalog.connection()) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
let _ = tx.send(PinMessage::Failed {
|
||||
message: e.to_string(),
|
||||
offline: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if tx
|
||||
.send(PinMessage::Planned {
|
||||
total: pending.len(),
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
if pending.is_empty() {
|
||||
let _ = tx.send(PinMessage::Done {
|
||||
stored: 0,
|
||||
bytes: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let rt = match tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
let _ = tx.send(PinMessage::Failed {
|
||||
message: e.to_string(),
|
||||
offline: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
rt.block_on(async {
|
||||
let backend = match NextcloudBackend::new(&creds, &user_id) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
let _ = tx.send(PinMessage::Failed {
|
||||
message: e.to_string(),
|
||||
offline: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut stored = 0usize;
|
||||
let mut bytes_total = 0u64;
|
||||
|
||||
for image in pending {
|
||||
let Some(source_ref) = source_ref_of(&catalog, image) else {
|
||||
// Catalogued and then removed while the pin was pending.
|
||||
continue;
|
||||
};
|
||||
|
||||
let id = RemoteId::Path(RemotePath::new(&source_ref));
|
||||
match backend.get(&id, None).await {
|
||||
Ok(bytes) => {
|
||||
// `pinned: true` — this is the population the budget
|
||||
// must never evict, which is the entire promise the
|
||||
// user made when they pinned the collection.
|
||||
if let Err(e) = store.store(
|
||||
catalog.connection(),
|
||||
image,
|
||||
&source_ref,
|
||||
&bytes,
|
||||
true,
|
||||
now_secs(),
|
||||
) {
|
||||
log::warn!("storing pinned {source_ref}: {e}");
|
||||
continue;
|
||||
}
|
||||
stored += 1;
|
||||
bytes_total += bytes.len() as u64;
|
||||
if tx.send(PinMessage::Stored { done: stored }).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(e) if e.indicates_offline() => {
|
||||
// Stop rather than failing each remaining file against
|
||||
// a dead connection. What was downloaded stays
|
||||
// downloaded, and `pending_pins` resumes from there.
|
||||
let _ = tx.send(PinMessage::Failed {
|
||||
message: e.to_string(),
|
||||
offline: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
// One unreadable file must not abandon the whole pin.
|
||||
log::warn!("pinning {source_ref}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = tx.send(PinMessage::Done {
|
||||
stored,
|
||||
bytes: bytes_total,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
rx
|
||||
}
|
||||
|
||||
/// The remote path for a catalogued image.
|
||||
fn source_ref_of(catalog: &Catalog, image: dr_types::ImageId) -> Option<String> {
|
||||
catalog
|
||||
.connection()
|
||||
.query_row(
|
||||
"SELECT source_ref FROM images WHERE id = ?1",
|
||||
rusqlite::params![image.0 as i64],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// TRACES: FR-NC-6a | FR-CAT-9
|
||||
/// Where a cached original is kept and how much may be kept.
|
||||
///
|
||||
/// Passed in rather than derived here so the caller owns the policy: the
|
||||
/// budget is a user setting, and this function is on a worker thread with no
|
||||
/// access to one.
|
||||
pub struct CacheContext {
|
||||
pub dir: PathBuf,
|
||||
pub catalog_path: PathBuf,
|
||||
pub image: dr_types::ImageId,
|
||||
pub budget: dr_catalog::Budget,
|
||||
/// Whether a downloaded original is kept.
|
||||
///
|
||||
/// Only the write. A cache is always *read*, because bytes already on disk
|
||||
/// cost nothing to use and declining them would re-download an image that
|
||||
/// is present — including every pinned one, which would leave a pinned
|
||||
/// collection unopenable offline the moment this was switched off.
|
||||
pub store: bool,
|
||||
}
|
||||
|
||||
/// Fetch one file in full, for opening it in develop.
|
||||
///
|
||||
/// Deliberately *not* the preview path. Browsing fetches a range and decodes
|
||||
@@ -817,16 +1011,50 @@ impl std::fmt::Display for FetchFailure {
|
||||
/// needs every photosite. On a RAW file that is tens of megabytes, which is
|
||||
/// why this is a click-triggered download and not something the grid does.
|
||||
///
|
||||
/// # Read-through
|
||||
///
|
||||
/// With a `cache`, this checks disk before the network and stores what it
|
||||
/// downloads. That is what makes opening the same photograph twice cost one
|
||||
/// transfer, and what leaves a working session's images openable offline
|
||||
/// without anyone having pinned anything.
|
||||
///
|
||||
/// A cache miss is not an error and a cache failure is not fatal: both fall
|
||||
/// through to the network, which is exactly the behaviour that existed before
|
||||
/// the cache did.
|
||||
///
|
||||
/// Returns the bytes on a channel rather than blocking: the download runs on
|
||||
/// its own thread and the UI stays live, exactly as thumbnail fetching does.
|
||||
pub fn spawn_full_fetch(
|
||||
creds: AppCredentials,
|
||||
user_id: String,
|
||||
path: String,
|
||||
cache: Option<CacheContext>,
|
||||
) -> Receiver<Result<Vec<u8>, FetchFailure>> {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
// Opened on this thread: `rusqlite::Connection` is not `Send`, and the
|
||||
// UI thread's handle cannot be borrowed across the spawn.
|
||||
let cached = cache.as_ref().and_then(|c| {
|
||||
let store = dr_catalog::Cache::open(&c.dir, c.budget).ok()?;
|
||||
let conn = Catalog::open(&c.catalog_path).ok()?;
|
||||
Some((store, conn))
|
||||
});
|
||||
|
||||
if let (Some(c), Some((store, conn))) = (cache.as_ref(), cached.as_ref()) {
|
||||
match store.load(conn.connection(), c.image, now_secs()) {
|
||||
Ok(Some(bytes)) => {
|
||||
log::info!("{path}: {} bytes from the local cache", bytes.len());
|
||||
let _ = tx.send(Ok(bytes));
|
||||
return;
|
||||
}
|
||||
Ok(None) => {}
|
||||
// A cache that cannot be read is a cache miss, not a failure
|
||||
// to open the photograph.
|
||||
Err(e) => log::debug!("cache lookup for {path}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
let rt = match tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
@@ -849,6 +1077,26 @@ pub fn spawn_full_fetch(
|
||||
|
||||
let id = RemoteId::Path(RemotePath::new(&path));
|
||||
let got = backend.get(&id, None).await.map_err(FetchFailure::from);
|
||||
|
||||
// Store before sending, so the bytes are on disk by the time the
|
||||
// image is on screen. Doing it after would leave a window where
|
||||
// closing the app immediately lost the download.
|
||||
if let (Ok(bytes), Some(c), Some((store, conn))) =
|
||||
(&got, cache.as_ref().filter(|c| c.store), cached.as_ref())
|
||||
{
|
||||
// `pinned: false` — this is the passive population. A pin is
|
||||
// something the user asks for explicitly; opening an image is
|
||||
// not that, and treating it as one would make the pinned set
|
||||
// grow silently and never be evicted.
|
||||
if let Err(e) =
|
||||
store.store(conn.connection(), c.image, &path, bytes, false, now_secs())
|
||||
{
|
||||
log::debug!("caching {path}: {e}");
|
||||
} else if let Err(e) = store.enforce(conn.connection()) {
|
||||
log::debug!("enforcing the cache budget: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
let _ = tx.send(got);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user