refactor(job-registry): simplify the job registering*

This commit is contained in:
Edouard Vanbelle
2026-07-28 21:13:09 +02:00
parent 4d64603233
commit 0e8b1fbbeb
8 changed files with 224 additions and 126 deletions
+16 -16
View File
@@ -792,24 +792,24 @@ the old fields needs updating.
### Config surface — env vars ### Config surface — env vars
Canonical form for every job (Part 1 or Part 2 alike, AND for core **No new convention.** Each service keeps its natural per-service
workers even though they don't register with the scheduler): prefix (`OXICLOUD_GRANT_CLEANUP_*`, `OXICLOUD_STORAGE_USAGE_*`, …).
The `GET /api/admin/jobs` endpoint already gives operators a runtime
view of every registered job's interval, so grepping env-var prefixes
is no longer the primary discovery path.
``` Earlier drafts proposed a uniform `OXICLOUD_JOB_<NAME>_INTERVAL_*`
OXICLOUD_JOB_<NAME>_ENABLED convention, with legacy names as warned aliases. Killed 2026-07-28
OXICLOUD_JOB_<NAME>_INTERVAL_HOURS # or _INTERVAL_SECS for sub-hour cadences (Ed): normalising only the interval knob while leaving domain-specific
OXICLOUD_JOB_<NAME>_<CUSTOM>... # e.g. _GRACE_HOURS, _BATCH_SIZE tunables (`GRACE_DAYS`, `BATCH_SIZE`, …) at the natural prefix creates
``` *intra-service* prefix drift — worse than the *cross-service* drift it
was meant to solve. A service either goes fully to `OXICLOUD_JOB_*`
(disruptive rename of every knob) or fully stays at its native prefix
(no rename). We stay.
Core workers reuse this naming purely for uniform operator ergonomics The one real gap is **trash_cleanup has no env var today** (hardcoded
(e.g. `OXICLOUD_JOB_TREE_ETAG_FLUSH_INTERVAL_MS`) — the convention is 24h in DI). Adding `OXICLOUD_TRASH_CLEANUP_INTERVAL_HOURS` when we
what operators grep for; whether the loop is scheduler-driven or a need it uses the natural prefix — no new convention needed.
dedicated `tokio::spawn` is an implementation detail they don't see.
Existing per-service env vars keep working as **aliases** during
migration — `OXICLOUD_GRANT_CLEANUP_INTERVAL_HOURS` reads first, falls
back to `OXICLOUD_JOB_GRANT_CLEANUP_INTERVAL_HOURS`. Deprecated aliases
warn once on startup and stay recognised through one minor version.
### Logging schema ### Logging schema
@@ -578,9 +578,25 @@ impl StorageUsageService {
pub const STORAGE_RECONCILE_JOB_NAME: &str = "storage_reconcile"; pub const STORAGE_RECONCILE_JOB_NAME: &str = "storage_reconcile";
use crate::infrastructure::scheduler::{JobHandler, JobOutcome, JobRunArgs}; use crate::infrastructure::scheduler::{JobHandler, JobOutcome, JobRegistry, JobRunArgs};
use async_trait::async_trait; use async_trait::async_trait;
impl StorageUsageService {
/// Register self with the periodic-job scheduler and return the
/// same `Arc<Self>` for DI-style chaining. Scheduled tenant with
/// interval = `max(30s, interval_secs)`. See
/// `docs/plan/job-registry.md` Part 1.
pub async fn register(
self: Arc<Self>,
registry: &JobRegistry,
interval_secs: u64,
) -> Arc<Self> {
let interval = Self::reconciliation_interval(interval_secs);
registry.register(self.clone(), Some(interval), None).await;
self
}
}
#[async_trait] #[async_trait]
impl JobHandler for StorageUsageService { impl JobHandler for StorageUsageService {
fn name(&self) -> &str { fn name(&self) -> &str {
+31 -77
View File
@@ -878,27 +878,18 @@ impl AppServiceFactory {
// (`docs/plan/job-registry.md` Part 1) instead of spawning its own // (`docs/plan/job-registry.md` Part 1) instead of spawning its own
// tokio interval loop. `SchedulerEngine::start` fires the actual // tokio interval loop. `SchedulerEngine::start` fires the actual
// supervisor task at the end of `build_app_state`. // supervisor task at the end of `build_app_state`.
let cleanup_service = Arc::new(TrashCleanupService::new( // Self-registering constructor chain — TrashCleanupService owns
// its interval + timeout shape; DI only supplies deps.
// `.register(&reg)` fires the uniform `job.registered` log line
// and panics on wiring error (duplicate name = boot must fail
// loud). See `docs/plan/job-registry.md` Part 1.
let _ = Arc::new(TrashCleanupService::new(
trash_repo.clone(), trash_repo.clone(),
core.dedup_service.clone(), core.dedup_service.clone(),
24, // Run cleanup every 24 hours 24, // Run cleanup every 24 hours
)); ))
let interval = cleanup_service.interval(); .register(&core.job_registry)
if let Err(e) = core .await;
.job_registry
.register(cleanup_service.clone(), Some(interval), None)
.await
{
// Duplicate registration is the only failure mode today and
// shouldn't happen in the normal DI flow. Log + continue so
// trash service still lands even if scheduling didn't.
tracing::error!("Failed to register trash_cleanup job with scheduler: {e}");
} else {
tracing::info!(
"Trash cleanup registered with scheduler (interval {} h)",
interval.as_secs() / 3600
);
}
Some(service as Arc<TrashService>) Some(service as Arc<TrashService>)
} }
@@ -1125,7 +1116,12 @@ impl AppServiceFactory {
// and invalidation would be a no-op observed by nobody — // and invalidation would be a no-op observed by nobody —
// this is the trap that regressed the used_bytes freshness // this is the trap that regressed the used_bytes freshness
// after perf commit `12dc648c`. // after perf commit `12dc648c`.
let service = Arc::new( // Keep cached storage usage fresh off the request path: GET
// /api/auth/me no longer recomputes the O(N) SUM per call; a
// periodic sweep does it instead (on the maintenance pool).
// Self-registering constructor chain — StorageUsageService owns
// its interval-clamping via `Self::reconciliation_interval`.
Arc::new(
crate::application::services::storage_usage_service::StorageUsageService::new( crate::application::services::storage_usage_service::StorageUsageService::new(
maintenance_pool.clone(), maintenance_pool.clone(),
user_repository, user_repository,
@@ -1134,28 +1130,9 @@ impl AppServiceFactory {
drive_repo drive_repo
as Arc<dyn crate::domain::repositories::drive_repository::DriveRepository>, as Arc<dyn crate::domain::repositories::drive_repository::DriveRepository>,
), ),
); )
// Keep cached storage usage fresh off the request path: GET .register(&core.job_registry, self.config.storage.usage_reconcile_secs)
// /api/auth/me no longer recomputes the O(N) SUM per call; a .await
// periodic sweep does it instead (on the maintenance pool).
// Registered with the periodic-job scheduler
// (`docs/plan/job-registry.md` Part 1); the retired
// `start_reconciliation_job` used to spawn its own interval loop.
let interval =
StorageUsageService::reconciliation_interval(self.config.storage.usage_reconcile_secs);
if let Err(e) = core
.job_registry
.register(service.clone(), Some(interval), None)
.await
{
tracing::error!("Failed to register storage_reconcile job: {e}");
} else {
tracing::info!(
"Storage-usage reconciliation registered with scheduler (interval {}s)",
interval.as_secs()
);
}
service
} }
/// Starts the tree-ETag flush job (requires database). /// Starts the tree-ETag flush job (requires database).
@@ -1279,22 +1256,13 @@ impl AppServiceFactory {
// Register on-demand-only jobs whose owning service lives on // Register on-demand-only jobs whose owning service lives on
// CoreServices. Dedup GC has NO periodic tick — trash cleanup's // CoreServices. Dedup GC has NO periodic tick — trash cleanup's
// sweep already runs GC as its tail step, so a periodic dedup // sweep already runs GC as its tail step, so a periodic dedup
// schedule would double the work. Registering with `interval = // schedule would double the work. The `register()` method
// None` keeps it admin-triggerable through the uniform scheduler // encapsulates the on-demand shape.
// surface (`POST /api/admin/jobs/dedup_gc/trigger`). let _ = core
if let Err(e) = core .dedup_service
.job_registry .clone()
.register( .register(&core.job_registry)
core.dedup_service.clone() as Arc<dyn crate::infrastructure::scheduler::JobHandler>, .await;
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) // 2. Repository services (requires PgPool for all metadata)
let repos = self.create_repository_services(&core, &pool); let repos = self.create_repository_services(&core, &pool);
@@ -1484,32 +1452,18 @@ impl AppServiceFactory {
self.start_content_index_job(&maintenance_pool, &core, content_index); self.start_content_index_job(&maintenance_pool, &core, content_index);
grant_cleanup_service = if core.config.features.grant_cleanup.enabled { grant_cleanup_service = if core.config.features.grant_cleanup.enabled {
// Self-registering constructor chain. Grant-cleanup owns
// its interval + on `?force=true` handling; DI only decides
// whether to instantiate at all (feature-gated).
let svc = Arc::new( let svc = Arc::new(
crate::infrastructure::services::grant_cleanup_service::GrantCleanupService::new( crate::infrastructure::services::grant_cleanup_service::GrantCleanupService::new(
authorization.clone(), authorization.clone(),
core.config.features.grant_cleanup.grace_days, core.config.features.grant_cleanup.grace_days,
core.config.features.grant_cleanup.interval_hours, core.config.features.grant_cleanup.interval_hours,
), ),
); )
// Registered with the periodic-job scheduler .register(&core.job_registry)
// (`docs/plan/job-registry.md` Part 1); the retired .await;
// `start_cleanup_job` used to spawn its own interval loop.
// Admin `?force=true` trigger still calls `svc.purge(Some(0))`
// directly — grace override doesn't fit the JobHandler shape.
let interval = svc.interval();
if let Err(e) = core
.job_registry
.register(svc.clone(), Some(interval), None)
.await
{
tracing::error!("Failed to register grant_cleanup job: {e}");
} else {
tracing::info!(
"Grant cleanup registered with scheduler (every {}h, grace = {}d)",
interval.as_secs() / 3600,
core.config.features.grant_cleanup.grace_days,
);
}
Some(svc) Some(svc)
} else { } else {
tracing::info!( tracing::info!(
+28 -6
View File
@@ -264,6 +264,10 @@ fn translate_join(
/// Distinct Ok/Err branches so the tracing macros pick up the fields at /// Distinct Ok/Err branches so the tracing macros pick up the fields at
/// compile time — `tracing` doesn't expand conditional field lists. /// compile time — `tracing` doesn't expand conditional field lists.
fn log_outcome(name: &str, outcome: &JobOutcome, cause: Option<ErrCause>, elapsed_ms: u128) { fn log_outcome(name: &str, outcome: &JobOutcome, cause: Option<ErrCause>, elapsed_ms: u128) {
// Also render elapsed inline in the human-readable message so
// `tail -f` operators see the duration without waiting on a
// structured log renderer to project the `elapsed_ms` field.
let elapsed = format_elapsed(elapsed_ms);
match outcome { match outcome {
JobOutcome::Ok { count, extra } => { JobOutcome::Ok { count, extra } => {
tracing::info!( tracing::info!(
@@ -274,8 +278,10 @@ fn log_outcome(name: &str, outcome: &JobOutcome, cause: Option<ErrCause>, elapse
count = *count, count = *count,
elapsed_ms = elapsed_ms, elapsed_ms = elapsed_ms,
extra = %extra, extra = %extra,
"job {} ran", "job {} ran in {} — count={}",
name, name,
elapsed,
count,
); );
} }
JobOutcome::Err { message: msg } => { JobOutcome::Err { message: msg } => {
@@ -287,13 +293,31 @@ fn log_outcome(name: &str, outcome: &JobOutcome, cause: Option<ErrCause>, elapse
cause = %cause.unwrap_or(ErrCause::Handler), cause = %cause.unwrap_or(ErrCause::Handler),
elapsed_ms = elapsed_ms, elapsed_ms = elapsed_ms,
error = %msg, error = %msg,
"job {} failed", "job {} failed after {} — {}",
name, name,
elapsed,
msg,
); );
} }
} }
} }
/// Human-friendly elapsed rendering — `12ms` / `340ms` / `1.4s` /
/// `12.3s` / `4m30s`. The structured `elapsed_ms` field still carries
/// the raw millisecond number for log aggregators.
fn format_elapsed(ms: u128) -> String {
if ms < 1000 {
format!("{}ms", ms)
} else if ms < 60_000 {
format!("{:.1}s", (ms as f64) / 1000.0)
} else {
let secs = ms / 1000;
let m = secs / 60;
let s = secs % 60;
format!("{}m{}s", m, s)
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -361,8 +385,7 @@ mod tests {
let registry = Arc::new(JobRegistry::new()); let registry = Arc::new(JobRegistry::new());
registry registry
.register(handler, Some(Duration::from_millis(100)), None) .register(handler, Some(Duration::from_millis(100)), None)
.await .await;
.unwrap();
let entry = registry.get("overrun").await.unwrap(); let entry = registry.get("overrun").await.unwrap();
// Kick off dispatch 1 in the background — it holds the permit // Kick off dispatch 1 in the background — it holds the permit
@@ -402,8 +425,7 @@ mod tests {
Some(Duration::from_millis(100)), Some(Duration::from_millis(100)),
Some(Duration::from_millis(50)), Some(Duration::from_millis(50)),
) )
.await .await;
.unwrap();
let entry = registry.get("slow").await.unwrap(); let entry = registry.get("slow").await.unwrap();
dispatch("slow", entry.clone(), &JobRunArgs::default()).await; dispatch("slow", entry.clone(), &JobRunArgs::default()).await;
+74 -20
View File
@@ -69,10 +69,7 @@ impl JobRegistry {
} }
} }
/// Register a job. Returns an error if a job with the same name /// Register a job — production wiring path.
/// is already registered — names are the primary identifier
/// everywhere (logs, admin URLs, env vars) and collisions would
/// hide bugs.
/// ///
/// - `interval = Some(dur)` → **scheduled**. The supervisor fires /// - `interval = Some(dur)` → **scheduled**. The supervisor fires
/// the job every `dur`, starting `now + dur`. Registration does /// the job every `dur`, starting `now + dur`. Registration does
@@ -83,11 +80,61 @@ impl JobRegistry {
/// fires this job. Admin endpoint (or programmatic callers) can /// fires this job. Admin endpoint (or programmatic callers) can
/// still invoke it via [`JobRegistry::trigger`] — the dispatch /// still invoke it via [`JobRegistry::trigger`] — the dispatch
/// goes through the same panic/timeout/exclusivity gates. /// goes through the same panic/timeout/exclusivity gates.
///
/// **Panics on error.** Registration failure (duplicate name today)
/// is a DI-wiring bug — the server must not start with a mis-wired
/// scheduler. Emits a uniform `job.registered` log line on success
/// so callers don't reinvent the log message at every site.
///
/// For unit tests that need to assert the error path use
/// [`Self::try_register`] instead.
pub async fn register( pub async fn register(
&self, &self,
handler: Arc<dyn JobHandler>, handler: Arc<dyn JobHandler>,
interval: Option<Duration>, interval: Option<Duration>,
timeout: Option<Duration>, timeout: Option<Duration>,
) {
let name = handler.name().to_string();
match self.try_register(handler, interval, timeout).await {
Ok(()) => {
let cadence = match interval {
Some(dur) => {
let secs = dur.as_secs();
if secs % 3600 == 0 {
format!("every {} h", secs / 3600)
} else if secs % 60 == 0 {
format!("every {} min", secs / 60)
} else {
format!("every {} s", secs)
}
}
None => "on-demand".to_string(),
};
tracing::info!(
target: "oxicloud::scheduler",
event = "job.registered",
job = %name,
cadence = %cadence,
"job {} registered ({})",
name,
cadence,
);
}
Err(e) => panic!(
"JobRegistry::register({name}) failed — DI wiring bug: {e}"
),
}
}
/// Fallible sibling of [`Self::register`]. Returns `Err` on
/// duplicate-name instead of panicking, and does NOT emit the
/// `job.registered` log line — for unit tests that need to
/// assert failure without triggering the boot panic path.
pub async fn try_register(
&self,
handler: Arc<dyn JobHandler>,
interval: Option<Duration>,
timeout: Option<Duration>,
) -> Result<(), RegisterError> { ) -> Result<(), RegisterError> {
let name = handler.name().to_string(); let name = handler.name().to_string();
let mut guard = self.entries.write().await; let mut guard = self.entries.write().await;
@@ -286,11 +333,9 @@ mod tests {
async fn register_and_pick_next() { async fn register_and_pick_next() {
let reg = JobRegistry::new(); let reg = JobRegistry::new();
reg.register(handler("job_a"), Some(Duration::from_secs(60)), None) reg.register(handler("job_a"), Some(Duration::from_secs(60)), None)
.await .await;
.unwrap();
reg.register(handler("job_b"), Some(Duration::from_secs(10)), None) reg.register(handler("job_b"), Some(Duration::from_secs(10)), None)
.await .await;
.unwrap();
let (next_name, _) = reg.pick_next().await.expect("expected a due job"); let (next_name, _) = reg.pick_next().await.expect("expected a due job");
// job_b has the shorter interval → earlier next_run_at. // job_b has the shorter interval → earlier next_run_at.
@@ -300,16 +345,30 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn duplicate_registration_rejected() { async fn duplicate_registration_rejected() {
let reg = JobRegistry::new(); let reg = JobRegistry::new();
reg.register(handler("job_x"), Some(Duration::from_secs(60)), None) // Use the fallible `try_register` here so we can assert the
// Err path without triggering `register`'s boot-time panic.
reg.try_register(handler("job_x"), Some(Duration::from_secs(60)), None)
.await .await
.unwrap(); .unwrap();
let err = reg let err = reg
.register(handler("job_x"), Some(Duration::from_secs(60)), None) .try_register(handler("job_x"), Some(Duration::from_secs(60)), None)
.await .await
.expect_err("duplicate name must be rejected"); .expect_err("duplicate name must be rejected");
assert!(matches!(err, RegisterError::DuplicateName(_))); assert!(matches!(err, RegisterError::DuplicateName(_)));
} }
#[tokio::test]
#[should_panic(expected = "DI wiring bug")]
async fn register_panics_on_duplicate() {
let reg = JobRegistry::new();
reg.register(handler("job_dup"), Some(Duration::from_secs(60)), None)
.await;
// Second register with same name — boot panic. Anything doing
// this outside a #[should_panic] test is a mis-wired DI.
reg.register(handler("job_dup"), Some(Duration::from_secs(60)), None)
.await;
}
#[tokio::test] #[tokio::test]
async fn empty_registry_picks_nothing() { async fn empty_registry_picks_nothing() {
let reg = JobRegistry::new(); let reg = JobRegistry::new();
@@ -320,11 +379,9 @@ mod tests {
async fn snapshot_all_returns_every_entry() { async fn snapshot_all_returns_every_entry() {
let reg = JobRegistry::new(); let reg = JobRegistry::new();
reg.register(handler("a"), Some(Duration::from_secs(1)), None) reg.register(handler("a"), Some(Duration::from_secs(1)), None)
.await .await;
.unwrap();
reg.register(handler("b"), Some(Duration::from_secs(1)), None) reg.register(handler("b"), Some(Duration::from_secs(1)), None)
.await .await;
.unwrap();
let all = reg.snapshot_all().await; let all = reg.snapshot_all().await;
assert_eq!(all.len(), 2); assert_eq!(all.len(), 2);
} }
@@ -334,12 +391,9 @@ mod tests {
let reg = JobRegistry::new(); let reg = JobRegistry::new();
// Scheduled job with a long interval. // Scheduled job with a long interval.
reg.register(handler("scheduled"), Some(Duration::from_secs(3600)), None) reg.register(handler("scheduled"), Some(Duration::from_secs(3600)), None)
.await .await;
.unwrap();
// On-demand job — supervisor must never pick it. // On-demand job — supervisor must never pick it.
reg.register(handler("on_demand"), None, None) reg.register(handler("on_demand"), None, None).await;
.await
.unwrap();
let (next_name, _) = reg.pick_next().await.expect("scheduled job due"); let (next_name, _) = reg.pick_next().await.expect("scheduled job due");
assert_eq!( assert_eq!(
@@ -351,7 +405,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn trigger_dispatches_on_demand_job() { async fn trigger_dispatches_on_demand_job() {
let reg = Arc::new(JobRegistry::new()); let reg = Arc::new(JobRegistry::new());
reg.register(handler("gc"), None, None).await.unwrap(); reg.register(handler("gc"), None, None).await;
let outcome = reg let outcome = reg
.trigger("gc", &JobRunArgs::default()) .trigger("gc", &JobRunArgs::default())
@@ -3127,6 +3127,29 @@ impl DedupPort for DedupService {
/// and admin URLs (`POST /api/admin/jobs/dedup_gc/trigger`). /// and admin URLs (`POST /api/admin/jobs/dedup_gc/trigger`).
pub const DEDUP_GC_JOB_NAME: &str = "dedup_gc"; pub const DEDUP_GC_JOB_NAME: &str = "dedup_gc";
impl DedupService {
/// Register self with the periodic-job scheduler and return the
/// same `Arc<Self>` for DI-style chaining. **On-demand only** —
/// registered with `interval = None`. The periodic GC role
/// belongs to trash cleanup (which invokes `garbage_collect()`
/// inline as its tail step); a duplicate scheduled tick here
/// would double the reclamation work. Registration exists solely
/// to expose the admin trigger uniformly.
pub async fn register(
self: std::sync::Arc<Self>,
registry: &crate::infrastructure::scheduler::JobRegistry,
) -> std::sync::Arc<Self> {
registry
.register(
self.clone() as std::sync::Arc<dyn crate::infrastructure::scheduler::JobHandler>,
None, // on-demand
None, // no timeout
)
.await;
self
}
}
#[async_trait::async_trait] #[async_trait::async_trait]
impl crate::infrastructure::scheduler::JobHandler for DedupService { impl crate::infrastructure::scheduler::JobHandler for DedupService {
fn name(&self) -> &str { fn name(&self) -> &str {
@@ -23,7 +23,7 @@ use tracing::{error, info};
use crate::application::ports::authorization_ports::AuthorizationEngine; use crate::application::ports::authorization_ports::AuthorizationEngine;
use crate::common::errors::DomainError; use crate::common::errors::DomainError;
use crate::infrastructure::scheduler::{JobHandler, JobOutcome, JobRunArgs}; use crate::infrastructure::scheduler::{JobHandler, JobOutcome, JobRegistry, JobRunArgs};
use crate::infrastructure::services::pg_acl_engine::PgAclEngine; use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
use async_trait::async_trait; use async_trait::async_trait;
@@ -57,12 +57,22 @@ impl GrantCleanupService {
self.grace_days self.grace_days
} }
/// Cadence exposed as `Duration` so DI passes a sanitised value /// Cadence exposed as `Duration`. Internal helper used by
/// (post-`.max(1)`) to `JobRegistry::register`. /// [`Self::register`]; kept `pub` for tests.
pub fn interval(&self) -> Duration { pub fn interval(&self) -> Duration {
Duration::from_secs(self.interval_hours * 3600) Duration::from_secs(self.interval_hours * 3600)
} }
/// Register self with the periodic-job scheduler and return the
/// same `Arc<Self>` for DI-style chaining. Scheduled tenant with
/// interval = `self.interval()`, no timeout. See
/// `docs/plan/job-registry.md` Part 1.
pub async fn register(self: Arc<Self>, registry: &JobRegistry) -> Arc<Self> {
let interval = self.interval();
registry.register(self.clone(), Some(interval), None).await;
self
}
/// Run one purge pass. /// Run one purge pass.
/// ///
/// `grace_override`: /// `grace_override`:
@@ -6,7 +6,7 @@ use tracing::{debug, error, info, instrument};
use crate::common::errors::Result; use crate::common::errors::Result;
use crate::domain::repositories::trash_repository::TrashRepository; use crate::domain::repositories::trash_repository::TrashRepository;
use crate::infrastructure::repositories::pg::trash_db_repository::TrashDbRepository; use crate::infrastructure::repositories::pg::trash_db_repository::TrashDbRepository;
use crate::infrastructure::scheduler::{JobHandler, JobOutcome, JobRunArgs}; use crate::infrastructure::scheduler::{JobHandler, JobOutcome, JobRegistry, JobRunArgs};
use crate::infrastructure::services::dedup_service::DedupService; use crate::infrastructure::services::dedup_service::DedupService;
use async_trait::async_trait; use async_trait::async_trait;
@@ -43,12 +43,31 @@ impl TrashCleanupService {
} }
} }
/// Registered interval as a `Duration` — helper for DI wiring so /// Registered interval as a `Duration`. Internal helper used by
/// the composition root doesn't reinvent the `hours × 3600` cast. /// [`Self::register`]; kept `pub` in case a test wants to assert
/// the clamped value.
pub fn interval(&self) -> Duration { pub fn interval(&self) -> Duration {
Duration::from_secs(self.cleanup_interval_hours * 3600) Duration::from_secs(self.cleanup_interval_hours * 3600)
} }
/// Register self with the periodic-job scheduler and return the
/// same `Arc<Self>` for DI-style method chaining:
///
/// ```ignore
/// let svc = Arc::new(TrashCleanupService::new(...))
/// .register(&core.job_registry)
/// .await;
/// ```
///
/// Scheduled tenant — interval reads from
/// `self.cleanup_interval_hours`, no timeout. See
/// `docs/plan/job-registry.md` Part 1 §Contract.
pub async fn register(self: Arc<Self>, registry: &JobRegistry) -> Arc<Self> {
let interval = self.interval();
registry.register(self.clone(), Some(interval), None).await;
self
}
/// Starts the periodic cleanup job /// Starts the periodic cleanup job
#[instrument(skip(self))] #[instrument(skip(self))]
pub async fn start_cleanup_job(&self) { pub async fn start_cleanup_job(&self) {