diff --git a/frontend/src/lib/components/AdminJobsPanel.svelte b/frontend/src/lib/components/AdminJobsPanel.svelte
index 66628b64..6c51e637 100644
--- a/frontend/src/lib/components/AdminJobsPanel.svelte
+++ b/frontend/src/lib/components/AdminJobsPanel.svelte
@@ -860,11 +860,17 @@
{mutatesLabel(job)}
{/if}
-
+
+
+
+ {cadenceLabel(job)}
{#if job.startup}
{/if}
|
- {cadenceLabel(job)} |
{timeAgo(job.last_run_at)} |
diff --git a/src/infrastructure/services/thumb_attached_import_service.rs b/src/infrastructure/services/thumb_attached_import_service.rs
index c5aa51d4..14748df6 100644
--- a/src/infrastructure/services/thumb_attached_import_service.rs
+++ b/src/infrastructure/services/thumb_attached_import_service.rs
@@ -89,15 +89,11 @@ impl ThumbAttachedImport {
registry: &JobRegistry,
provider: &Arc,
) -> Arc {
- // Daily, matching `thumb_derived_import` — and it does not delete on
- // the tick either, since `repair` defaults false. See that job for
- // the reasoning.
+ // On-demand, matching `thumb_derived_import` — the boot run in repair
+ // mode is the migration, and a tick could not finish it anyway
+ // because ticks never pass `repair`. See that job for the reasoning.
registry
- .register_recoverable_job(
- self.clone(),
- provider.clone(),
- Some(std::time::Duration::from_secs(24 * 3600)),
- )
+ .register_recoverable_job(self.clone(), provider.clone(), None)
.await;
self
}
@@ -480,6 +476,20 @@ impl RecoverableJobHandler for ThumbAttachedImport {
}
}
+ // Both jobs attempt the teardown, and it no-ops unless the tree is
+ // drained of files EITHER of them claims. Without this, whichever
+ // job runs last leaves an empty `.thumbnails/` behind until the
+ // next boot; with it, the tree disappears in the same run that
+ // empties it, whatever order the two ran in.
+ if delete_imported {
+ crate::infrastructure::services::thumb_derived_import_service::teardown_if_drained(
+ &self.thumbnails_root,
+ THUMB_ATTACHED_IMPORT_JOB_NAME,
+ &store.run_id().to_string(),
+ )
+ .await;
+ }
+
tracing::info!(
target: "oxicloud::dedup",
event = "thumb_attached_import.completed",
diff --git a/src/infrastructure/services/thumb_derived_import_service.rs b/src/infrastructure/services/thumb_derived_import_service.rs
index dd9a2d43..372005d2 100644
--- a/src/infrastructure/services/thumb_derived_import_service.rs
+++ b/src/infrastructure/services/thumb_derived_import_service.rs
@@ -96,6 +96,123 @@ pub(crate) fn audit_sidecar_deleted(
/// blob write, so this is deliberately smaller than a pure-DB sweep's page.
const BATCH_SIZE: usize = 100;
+/// Remove `.thumbnails/` — but only once BOTH import jobs have drained it.
+///
+/// The directory is shared and each job owns half of it: hash-named
+/// sidecars belong to `thumb_derived_import`, `ext-{file_id}.jpg` to
+/// `thumb_attached_import`. Whichever runs first therefore finds the
+/// other's files still present.
+///
+/// The first version let the derived job tear down unilaterally. It ran
+/// first, deleted its own sidecars, found `remove_dir` refused because the
+/// `ext-*` previews were still there, and fell back to renaming the tree
+/// to `.thumbnails.migrated`. The attached job then looked in
+/// `.thumbnails/`, found nothing, and reported zeros — stranding the
+/// user-uploaded previews, which are the one class of file here that
+/// cannot be regenerated. The rename fired for exactly the wrong reason:
+/// it exists for files NEITHER job claims, and it fired for the sibling's
+/// work-in-progress.
+///
+/// So the rule is: if anything remains that either job would claim, do
+/// nothing at all and let the sibling finish. Whichever job runs last then
+/// finds a genuinely empty tree and removes it, in the same boot.
+///
+/// The rename survives for its original purpose only — a file no job
+/// claims (Finder's `.DS_Store`) blocking `remove_dir` forever, which
+/// would keep the read fallback alive on every developer machine.
+pub(crate) async fn teardown_if_drained(root: &std::path::Path, job: &str, run_id: &str) {
+ let mut claimed_remaining = 0usize;
+ let mut foreign_remaining = 0usize;
+
+ for size in ThumbnailSize::all() {
+ let dir = root.join(size.dir_name());
+ let Ok(mut entries) = fs::read_dir(&dir).await else {
+ continue; // already gone
+ };
+ while let Ok(Some(entry)) = entries.next_entry().await {
+ match entry.file_name().to_str() {
+ // Either job's file. `hash_from_sidecar_name` covers the
+ // content-keyed sidecars, the `ext-` prefix the file-keyed
+ // previews; between them that is everything a migration
+ // still has to move.
+ Some(name)
+ if ThumbDerivedImport::hash_from_sidecar_name(name).is_some()
+ || name.starts_with("ext-") =>
+ {
+ claimed_remaining += 1;
+ }
+ _ => foreign_remaining += 1,
+ }
+ }
+ }
+
+ if claimed_remaining > 0 {
+ tracing::info!(
+ target: "oxicloud::dedup",
+ event = "thumbnail.teardown_deferred",
+ job = job,
+ run_id = run_id,
+ remaining = claimed_remaining,
+ "legacy sidecar directory left in place — {claimed_remaining} file(s) still \
+ belong to the sibling import job, which has not finished draining them"
+ );
+ return;
+ }
+
+ for size in ThumbnailSize::all() {
+ let _ = fs::remove_dir(root.join(size.dir_name())).await;
+ }
+
+ match fs::remove_dir(root).await {
+ Ok(()) => tracing::info!(
+ target: "oxicloud::dedup",
+ event = "thumbnail.root_removed",
+ job = job,
+ run_id = run_id,
+ path = %root.display(),
+ "🧹 legacy sidecar directory removed — the fallback read path is inert \
+ from the next restart"
+ ),
+ Err(e) if foreign_remaining > 0 => {
+ // `with_file_name`, NOT `with_extension`: `.thumbnails` is all
+ // stem to `Path`, so `with_extension` would have produced
+ // `.thumbnails.thumbnails.migrated`.
+ let parked = root.with_file_name(PARKED_DIR_NAME);
+ match fs::rename(root, &parked).await {
+ Ok(()) => tracing::info!(
+ target: "oxicloud::dedup",
+ event = "thumbnail.root_parked",
+ job = job,
+ run_id = run_id,
+ to = %parked.display(),
+ foreign = foreign_remaining,
+ "🧹 legacy sidecar directory holds {foreign_remaining} file(s) no import \
+ job claims — moved aside instead of deleted, so nothing of anyone \
+ else's is destroyed. Safe to remove by hand."
+ ),
+ Err(e) => tracing::warn!(
+ target: "oxicloud::dedup",
+ event = "thumbnail.root_kept",
+ job = job,
+ run_id = run_id,
+ reason = %e,
+ "legacy sidecar directory neither removed nor moved aside — the \
+ fallback read path stays live"
+ ),
+ }
+ let _ = e;
+ }
+ Err(e) => tracing::warn!(
+ target: "oxicloud::dedup",
+ event = "thumbnail.root_kept",
+ job = job,
+ run_id = run_id,
+ reason = %e,
+ "legacy sidecar directory could not be removed"
+ ),
+ }
+}
+
pub struct ThumbDerivedImport {
thumbnails_root: PathBuf,
dedup: Arc,
@@ -124,12 +241,19 @@ impl ThumbDerivedImport {
// per no-silent-auto-repair. Once drained, a run is a `read_dir` over
// three directories that returns nothing — and after the directory is
// removed, not even that.
+ // On-demand, NOT periodic.
+ //
+ // `OXICLOUD_STARTUP_JOBS` runs this at boot in repair mode, and that
+ // is the whole migration: nothing has written a sidecar since step
+ // 10d2, so the tail cannot grow after startup. A daily tick could
+ // only ever redo work the boot run already did — and it would do it
+ // WITHOUT repair, so it could not even finish the job. Once drained
+ // it is a `read_dir` returning nothing, every day, forever.
+ //
+ // The admin trigger remains for operators who want to re-run it by
+ // hand, which is the case registration exists for.
registry
- .register_recoverable_job(
- self.clone(),
- provider.clone(),
- Some(std::time::Duration::from_secs(24 * 3600)),
- )
+ .register_recoverable_job(self.clone(), provider.clone(), None)
.await;
self
}
@@ -605,67 +729,12 @@ impl RecoverableJobHandler for ThumbDerivedImport {
// emptiness check of its own and cannot race a concurrent write into
// deleting live files.
if delete_imported {
- for size in ThumbnailSize::all() {
- let dir = self.thumbnails_root.join(size.dir_name());
- let _ = fs::remove_dir(&dir).await;
- }
- // Report the root, rather than discarding the result as the size
- // directories do. This is the one outcome an operator is waiting
- // for — absence is what makes the fallback inert — and it fails
- // for a reason worth naming: on macOS Finder leaves a `.DS_Store`
- // in the root, so `remove_dir` refuses forever while every
- // sidecar underneath is long gone.
- match fs::remove_dir(&self.thumbnails_root).await {
- Ok(()) => tracing::info!(
- target: "oxicloud::dedup",
- event = "thumb_derived_import.root_removed",
- run_id = %store.run_id(),
- path = %self.thumbnails_root.display(),
- "🧹 legacy sidecar directory removed — the fallback read path \
- is inert from the next restart"
- ),
- // Something unrelated to thumbnails is in the directory, so
- // `remove_dir` refuses. On macOS that is Finder's `.DS_Store`,
- // and it would otherwise keep the fallback alive forever on
- // every developer machine.
- //
- // Move the whole tree aside instead. The sidecars are already
- // imported and verified, so nothing here is load-bearing; what
- // matters is that `.thumbnails` stops existing, because its
- // absence is what the read path tests. Renaming preserves the
- // stray file for whoever put it there, and keeps the check a
- // single `stat` rather than a directory walk.
- Err(_) => {
- // `with_file_name`, NOT `with_extension`: the directory is
- // `.thumbnails`, and a leading-dot name has no extension as
- // far as `Path` is concerned — its whole name is the stem.
- // `with_extension("thumbnails.migrated")` would have
- // produced `.thumbnails.thumbnails.migrated`.
- let parked = self.thumbnails_root.with_file_name(PARKED_DIR_NAME);
- match fs::rename(&self.thumbnails_root, &parked).await {
- Ok(()) => tracing::info!(
- target: "oxicloud::dedup",
- event = "thumb_derived_import.root_parked",
- run_id = %store.run_id(),
- from = %self.thumbnails_root.display(),
- to = %parked.display(),
- "🧹 legacy sidecar directory could not be removed (a \
- non-sidecar file remains) — moved aside instead. The \
- fallback read path is inert from the next restart; \
- the directory is safe to delete by hand."
- ),
- Err(e) => tracing::warn!(
- target: "oxicloud::dedup",
- event = "thumb_derived_import.root_kept",
- run_id = %store.run_id(),
- path = %self.thumbnails_root.display(),
- reason = %e,
- "legacy sidecar directory could be neither removed nor \
- moved aside — the fallback read path stays live"
- ),
- }
- }
- }
+ teardown_if_drained(
+ &self.thumbnails_root,
+ THUMB_DERIVED_IMPORT_JOB_NAME,
+ &store.run_id().to_string(),
+ )
+ .await;
}
tracing::info!(
|