Files
dtourolle b1d1c47261 Start every worker thread through the executors module
Thirty-nine spawn sites in dr-ui, and one in the Android entry point,
called std::thread::spawn or a Builder of their own, and most of the
threads they started were <unnamed> in a panic message or a profiler.
Each now calls executors::spawn with its executor and a role, so the thread is
named <executor>:<role> — net:sync, decode:thumbs, io:catalog-open —
and knows which executor it is on. The three that already set a name
(automation, import, prefetch) keep their name as the role.

Behaviour is unchanged: each job still gets a thread of its own when it
starts, and spawn panics where std::thread::spawn did.

The module's documentation now says how a job is assigned: by what it
spends its time on, so a sweep that fetches bytes and then decodes them
is Decode, and a sidecar write that touches the catalog is Network.

Left as they were: the segmentation and refine workers in masks_ui.rs,
which another change is reworking, and test-only threads.
2026-09-27 07:08:37 -04:00

503 lines
18 KiB
Rust

//! TRACES: FR-CAT-15 | NFR-P9
//! Soft delete, restore, and permanent delete against the remote.
//!
//! `dr_catalog::trash` owns the catalog side and does no I/O. This is the other
//! half: the remote `MOVE`/`DELETE`, run on a worker thread, paired with the
//! catalog record in the one order that is safe.
//!
//! # Ordering, which is the whole of the correctness here
//!
//! **Soft delete** — `MOVE` first, record second. The reverse would leave the
//! catalog claiming a file is trashed while it sits in the library; the scan
//! excludes the trash folder, so nothing would ever correct the row.
//!
//! **Restore** — `MOVE` first, record second, for the same reason mirrored.
//!
//! **Purge** — `DELETE` the file, then forget the row, then forget the
//! thumbnail. A crash between steps leaves a trashed row whose file is gone,
//! which the next empty resolves as already-deleted. The other order loses the
//! file silently: no row, no listing, and a scan that will never look in the
//! trash folder — a photograph consuming quota that nothing can find.
//!
//! # Partial failure is normal, not exceptional
//!
//! Forty files is forty requests, and one can fail on permissions while the
//! rest succeed. Every operation here is therefore per-image and reports what
//! actually happened rather than aborting the batch — a trash that gives up
//! halfway with no record of where it stopped is worse than one that reports
//! "38 of 40".
use crate::executors::{self, Executor};
use std::path::PathBuf;
use std::sync::mpsc::Receiver;
use dr_catalog::{trash, Catalog};
use dr_sync::{Connection, RemoteError, RemoteId, RemotePath};
use dr_types::ImageId;
/// What a trash operation reports back to the UI.
#[derive(Debug)]
pub enum TrashMessage {
/// One image finished, successfully or not.
///
/// Per-image rather than per-batch so the UI can show progress on a large
/// selection, and so a failure names the file it happened to.
Progress {
done: usize,
total: usize,
failed: usize,
},
/// The batch finished. `failed` names what did not work, for the status line.
Done { moved: usize, failed: Vec<String> },
}
/// Which way an image is being moved.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Direction {
/// Library → trash folder.
ToTrash,
/// Trash folder → where it came from.
Restore,
}
/// One image to move, resolved before the worker starts.
///
/// Carries the paths the `MOVE` runs between, so the worker needs no catalog
/// access to do its half — the catalog is not `Send`, and the worker owns a
/// separate connection only for the write-back.
#[derive(Debug, Clone)]
pub struct Move {
pub image_id: ImageId,
/// `oc:fileid` where the catalog knows one — the identity `MOVE` preserves,
/// which is what keeps the thumbnail and sidecar mapping attached across a
/// trash and restore.
///
/// Not how the file is addressed: the `MOVE` goes from `from` to `to`,
/// because WebDAV exposes no fileid-addressable endpoint and the backend
/// rejects a bare `RemoteId::Stable`. Carried so the plan records the
/// identity it expects to survive.
pub file_id: Option<u64>,
pub from: String,
pub to: String,
}
/// Plan a soft delete: where each image goes in the trash.
///
/// Reads the catalog, so it runs on the UI thread before the worker starts.
/// Skips images already trashed — re-trashing is a no-op, not an error, and the
/// UI can hand over a selection that overlaps the trash.
pub fn plan_trash(
catalog: &Catalog,
root: &str,
images: &[ImageId],
) -> Result<Vec<Move>, dr_catalog::CatalogError> {
let mut out = Vec::new();
for &image in images {
let row: Option<(String, Option<i64>, Option<i64>)> = catalog
.connection()
.query_row(
"SELECT i.source_ref, r.file_id, i.trashed_at
FROM images i LEFT JOIN remote r ON r.image_id = i.id
WHERE i.id = ?1",
[image.0 as i64],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.ok();
let Some((from, file_id, trashed_at)) = row else {
continue;
};
if trashed_at.is_some() {
continue;
}
out.push(Move {
image_id: image,
file_id: file_id.map(|v| v as u64),
to: trash::trash_path(root, image, &from),
from,
});
}
Ok(out)
}
/// Plan a restore: where each trashed image goes back to.
///
/// An image with no recorded origin is skipped rather than guessed at — putting
/// a photograph in the wrong folder is harder to notice, and harder to undo,
/// than leaving it in the trash.
pub fn plan_restore(
catalog: &Catalog,
images: &[ImageId],
) -> Result<Vec<Move>, dr_catalog::CatalogError> {
let mut out = Vec::new();
for &image in images {
let row: Option<(String, Option<String>, Option<i64>)> = catalog
.connection()
.query_row(
"SELECT i.source_ref, i.trashed_from, r.file_id
FROM images i LEFT JOIN remote r ON r.image_id = i.id
WHERE i.id = ?1 AND i.trashed_at IS NOT NULL",
[image.0 as i64],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.ok();
let Some((from, Some(to), file_id)) = row else {
continue;
};
out.push(Move {
image_id: image,
file_id: file_id.map(|v| v as u64),
from,
to,
});
}
Ok(out)
}
/// Move images to or from the trash on a worker thread.
///
/// The catalog is written by the worker, after each successful move, so an
/// interrupted batch leaves the rows it completed correct rather than losing all
/// of them.
pub fn spawn_move(
conn: Connection,
moves: Vec<Move>,
direction: Direction,
catalog_path: PathBuf,
) -> Receiver<TrashMessage> {
let (tx, rx) = std::sync::mpsc::channel();
executors::spawn(Executor::Network, "trash", move || {
let total = moves.len();
let rt = match crate::net_runtime::build() {
Ok(rt) => rt,
Err(e) => {
let _ = tx.send(TrashMessage::Done {
moved: 0,
failed: vec![e.to_string()],
});
return;
}
};
rt.block_on(async {
let backend = match crate::remote::connect(&conn) {
Ok(b) => b,
Err(e) => {
let _ = tx.send(TrashMessage::Done {
moved: 0,
failed: vec![e.to_string()],
});
return;
}
};
let mut succeeded: Vec<(ImageId, String)> = Vec::new();
let mut failed: Vec<String> = Vec::new();
for (i, mv) in moves.iter().enumerate() {
// Addressed by path, not by `mv.file_id`: WebDAV has no
// fileid-addressable endpoint, so a `RemoteId::Stable` here is
// rejected by the backend. The fileid is an identity that the
// MOVE preserves, not a way to name the source.
let id = RemoteId::Path(RemotePath::new(&mv.from));
match backend.move_to(&id, &RemotePath::new(&mv.to)).await {
Ok(()) => {
// The fileid is logged, not sent: if a restore later
// shows a missing thumbnail, this is the record of which
// identity the MOVE was supposed to carry across.
if let Some(f) = mv.file_id {
log::debug!("moved {} to {} as fileid {f}", mv.from, mv.to);
}
succeeded.push((mv.image_id, mv.to.clone()));
}
Err(e) => {
// Named by file, not by id: the user recognises the
// filename and cannot do anything with a row number.
let name = mv.from.rsplit('/').next().unwrap_or(&mv.from);
log::warn!("moving {name}: {e}");
failed.push(format!("{name}: {e}"));
}
}
let _ = tx.send(TrashMessage::Progress {
done: i + 1,
total,
failed: failed.len(),
});
}
// Record after the moves, in one transaction. A crash before this
// leaves the files moved and the catalog stale — recoverable,
// because the next scan cannot see them in the trash folder and the
// rows still point at paths that 404, which the UI reports.
if !succeeded.is_empty() {
match Catalog::open(&catalog_path) {
Ok(cat) => {
let result = match direction {
Direction::ToTrash => {
trash::record_trashed(cat.connection(), &succeeded, now_secs())
}
Direction::Restore => {
trash::record_restored(cat.connection(), &succeeded)
}
};
if let Err(e) = result {
log::warn!("recording trash state: {e}");
failed.push(format!("catalog: {e}"));
}
}
Err(e) => {
log::warn!("opening catalog to record trash state: {e}");
failed.push(format!("catalog: {e}"));
}
}
}
let _ = tx.send(TrashMessage::Done {
moved: succeeded.len(),
failed,
});
});
});
rx
}
/// Permanently delete trashed images: the file, then the row, then the thumbnail.
///
/// See the module preamble for why that order. `thumbs_dir` is passed so the
/// worker can drop the previews — the shards sync, so a stale entry would keep
/// serving a preview of a deleted photograph on every device.
pub fn spawn_purge(
conn: Connection,
images: Vec<ImageId>,
paths: Vec<(ImageId, Option<u64>, String)>,
catalog_path: PathBuf,
thumbs_dir: PathBuf,
) -> Receiver<TrashMessage> {
let (tx, rx) = std::sync::mpsc::channel();
executors::spawn(Executor::Network, "purge", move || {
let total = paths.len();
let rt = match crate::net_runtime::build() {
Ok(rt) => rt,
Err(e) => {
let _ = tx.send(TrashMessage::Done {
moved: 0,
failed: vec![e.to_string()],
});
return;
}
};
rt.block_on(async {
let backend = match crate::remote::connect(&conn) {
Ok(b) => b,
Err(e) => {
let _ = tx.send(TrashMessage::Done {
moved: 0,
failed: vec![e.to_string()],
});
return;
}
};
let mut deleted: Vec<ImageId> = Vec::new();
let mut dead_thumbs: Vec<u64> = Vec::new();
let mut failed: Vec<String> = Vec::new();
for (i, (image, file_id, path)) in paths.iter().enumerate() {
// By path — see `spawn_move`. `file_id` still matters below, as
// the key the thumbnail shards are stored under.
let id = RemoteId::Path(RemotePath::new(path));
match backend.delete(&id, None).await {
Ok(()) => {
deleted.push(*image);
if let Some(f) = file_id {
dead_thumbs.push(*f);
}
}
// Already gone is the goal state, not a failure. Treating it
// as an error would wedge every future empty-trash on a file
// the user had removed by hand.
Err(e) if is_missing(&e) => {
log::debug!("{path} was already gone");
deleted.push(*image);
if let Some(f) = file_id {
dead_thumbs.push(*f);
}
}
Err(e) => {
let name = path.rsplit('/').next().unwrap_or(path);
log::warn!("deleting {name}: {e}");
failed.push(format!("{name}: {e}"));
}
}
let _ = tx.send(TrashMessage::Progress {
done: i + 1,
total,
failed: failed.len(),
});
}
// Rows after the files — see `trash::purge_order`.
if !deleted.is_empty() {
match Catalog::open(&catalog_path) {
Ok(cat) => {
if let Err(e) = trash::forget(cat.connection(), &deleted) {
log::warn!("forgetting purged rows: {e}");
failed.push(format!("catalog: {e}"));
}
}
Err(e) => failed.push(format!("catalog: {e}")),
}
}
// Thumbnails last. A failure here is logged and dropped: the
// photographs are gone, which was the point, and a stale preview is
// a cosmetic problem rather than a reason to report the delete
// failed.
if !dead_thumbs.is_empty() {
match dr_thumbs::ThumbStore::open(&thumbs_dir) {
Ok(mut store) => match store.forget(&dead_thumbs) {
Ok(n) => log::info!("dropped {n} thumbnail(s) for purged images"),
Err(e) => log::warn!("dropping thumbnails: {e}"),
},
Err(e) => log::warn!("opening thumbnail store to drop previews: {e}"),
}
}
let _ = tx.send(TrashMessage::Done {
moved: deleted.len(),
failed,
});
});
let _ = images;
});
rx
}
/// Whether a remote error means the object is not there.
///
/// Kept next to its use rather than in `dr-catalog`: it inspects a
/// `dr_sync::RemoteError`, and the catalog crate neither sees nor should see
/// that type.
fn is_missing(e: &RemoteError) -> bool {
matches!(e, RemoteError::NotFound(_))
}
fn now_secs() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
fn seeded() -> Catalog {
let cat = Catalog::in_memory().unwrap();
let c = cat.connection();
c.execute(
"INSERT INTO roots(id, kind, label) VALUES (1, 'remote', 'PhotosRaw')",
[],
)
.unwrap();
for i in 1..=3i64 {
c.execute(
"INSERT INTO images(id, root_id, source_ref, file_size, added_at)
VALUES (?1, 1, ?2, 1000, 0)",
rusqlite::params![i, format!("PhotosRaw/2019/IMG_{i:04}.CR2")],
)
.unwrap();
c.execute(
"INSERT INTO remote(image_id, file_id) VALUES (?1, ?2)",
rusqlite::params![i, 500 + i],
)
.unwrap();
}
cat
}
fn img(i: u64) -> ImageId {
ImageId(i)
}
#[test]
fn a_trash_plan_targets_the_trash_folder_and_keeps_the_stable_id() {
let cat = seeded();
let plan = plan_trash(&cat, "PhotosRaw", &[img(1)]).unwrap();
assert_eq!(plan.len(), 1);
assert_eq!(plan[0].from, "PhotosRaw/2019/IMG_0001.CR2");
assert!(plan[0].to.contains(".darkroom-trash"), "{}", plan[0].to);
// The stable id is what makes the move keep the thumbnail attached.
assert_eq!(plan[0].file_id, Some(501));
}
#[test]
fn an_already_trashed_image_is_not_trashed_again() {
// The selection can overlap what is already in the trash; a second move
// would relocate the file *within* the trash and lose its origin.
let cat = seeded();
let plan = plan_trash(&cat, "PhotosRaw", &[img(1)]).unwrap();
trash::record_trashed(cat.connection(), &[(img(1), plan[0].to.clone())], 100).unwrap();
assert!(plan_trash(&cat, "PhotosRaw", &[img(1)]).unwrap().is_empty());
}
#[test]
fn a_restore_plan_sends_each_image_back_where_it_came_from() {
let cat = seeded();
let plan = plan_trash(&cat, "PhotosRaw", &[img(1)]).unwrap();
trash::record_trashed(cat.connection(), &[(img(1), plan[0].to.clone())], 100).unwrap();
let back = plan_restore(&cat, &[img(1)]).unwrap();
assert_eq!(back.len(), 1);
assert_eq!(back[0].to, "PhotosRaw/2019/IMG_0001.CR2");
// And it moves *from* the trash.
assert!(back[0].from.contains(".darkroom-trash"));
}
#[test]
fn restoring_an_untrashed_image_plans_nothing() {
let cat = seeded();
assert!(plan_restore(&cat, &[img(2)]).unwrap().is_empty());
}
#[test]
fn a_missing_image_is_skipped_rather_than_planned_against_nothing() {
// The grid's selection can outlive a rescan that removed a row.
let cat = seeded();
assert!(plan_trash(&cat, "PhotosRaw", &[img(999)])
.unwrap()
.is_empty());
assert!(plan_restore(&cat, &[img(999)]).unwrap().is_empty());
}
#[test]
fn a_not_found_on_delete_counts_as_deleted() {
// Otherwise one hand-removed file wedges every future empty-trash.
assert!(is_missing(&RemoteError::NotFound("x".into())));
assert!(!is_missing(&RemoteError::Unsupported("x")));
}
#[test]
fn planning_over_an_empty_selection_is_a_no_op() {
let cat = seeded();
assert!(plan_trash(&cat, "PhotosRaw", &[]).unwrap().is_empty());
assert!(plan_restore(&cat, &[]).unwrap().is_empty());
}
}