diff --git a/src/common/di.rs b/src/common/di.rs index e38524a7..5c591fc9 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -1277,6 +1277,27 @@ impl AppServiceFactory { // 1. Core services (PgPool needed for DedupService index) let core = self.create_core_services(&pool, &maintenance_pool).await?; + // Register on-demand-only jobs whose owning service lives on + // CoreServices. Dedup GC has NO periodic tick — trash cleanup's + // sweep already runs GC as its tail step, so a periodic dedup + // schedule would double the work. Registering with `interval = + // None` keeps it admin-triggerable through the uniform scheduler + // surface (`POST /api/admin/internal/trigger-job/dedup_gc`). + if let Err(e) = core + .job_registry + .register( + core.dedup_service.clone() + as Arc, + None, // on-demand only + None, // no timeout + ) + .await + { + tracing::error!("Failed to register dedup_gc job with scheduler: {e}"); + } else { + tracing::info!("Dedup GC registered with scheduler (on-demand only)"); + } + // 2. Repository services (requires PgPool for all metadata) let repos = self.create_repository_services(&core, &pool); diff --git a/src/infrastructure/services/dedup_service.rs b/src/infrastructure/services/dedup_service.rs index a4756086..199997b1 100644 --- a/src/infrastructure/services/dedup_service.rs +++ b/src/infrastructure/services/dedup_service.rs @@ -3120,6 +3120,46 @@ impl DedupPort for DedupService { } } +// ─── JobRegistry integration ──────────────────────────────────────────────── + +/// Registered name for the dedup GC job. Stable identifier used in +/// log lines, `admin.background_runs.job_name` (when Part 2 lands), +/// and admin URLs (`POST /api/admin/internal/trigger-job/dedup_gc`). +pub const DEDUP_GC_JOB_NAME: &str = "dedup_gc"; + +#[async_trait::async_trait] +impl crate::infrastructure::scheduler::JobHandler for DedupService { + fn name(&self) -> &str { + DEDUP_GC_JOB_NAME + } + + /// Runs one `garbage_collect` sweep — the same reclamation that + /// `TrashCleanupService` invokes inline as its tail step, exposed + /// through the scheduler so operators can trigger it uniformly via + /// `POST /api/admin/internal/trigger-job/dedup_gc`. + /// + /// Registered with `interval = None` (on-demand only): the periodic + /// tick belongs to trash cleanup, whose sweep already runs GC as + /// its final phase. Registering a redundant periodic tick here + /// would double the reclamation work with no benefit; the admin + /// trigger is what the registered entry buys us — uniform log lines, + /// panic containment, exclusivity vs. any concurrent trigger. + /// + /// `count` reports blobs reclaimed; `extra.bytes_reclaimed` reports + /// the freed disk. GC returning `(0, 0)` is normal — it means trash + /// cleanup already reaped everything. + async fn run(&self) -> crate::infrastructure::scheduler::JobOutcome { + use crate::infrastructure::scheduler::JobOutcome; + match self.garbage_collect().await { + Ok((items, bytes)) => JobOutcome::ok_with( + items, + serde_json::json!({ "bytes_reclaimed": bytes }), + ), + Err(e) => JobOutcome::Err(format!("dedup GC failed: {e}")), + } + } +} + // ─── Tests ─────────────────────────────────────────────────────────────────── #[cfg(test)]