feat(jobs): jobs declare their own run parameters
`JobRunArgs` was a fixed struct — `force`, `deep`, `storage`, `repair` — and six places hardcoded that same list: the engine's persist/restore, the trigger endpoint's query type, the OXICLOUD_STARTUP_JOBS parser, the frontend API wrapper, the panel's checkboxes, and `StartupTrigger` on the wire. Two costs. Adding a parameter meant editing all six, and forgetting one dropped it silently — most damagingly in persist/restore, where a resumed run lost it and a `?repair=true` migration came back as discovery-only after a restart. And the panel offered the same knobs on every job: only two jobs read `deep`, six read `repair`, so most of those controls did nothing with no way to tell which. Now `JobHandler::parameters()` returns `&'static [JobParam]` — name, type (boolean/string/number), default, and the job's own description of what it does. `JobRunArgs` holds a map keyed by those names. Everything reads the declaration: * `run_or_resume` iterates it to persist and restore, replacing `const FLAGS` plus a `storage` special case. `storage` stops being special — it was the one Option<String> among three bools. * `dispatch` normalises every run against it, which is what makes "a handler sees its declared parameters with their declared defaults" true rather than usual. The periodic tick passes an empty `JobRunArgs::default()`, so a `default: true` parameter would otherwise read false on every scheduled run. * The trigger endpoint takes free-form query params and rejects undeclared ones with a 400 naming the real set, instead of ignoring them. * OXICLOUD_STARTUP_JOBS keeps raw pairs (config is parsed before the registry exists) and validates at dispatch, where the error can name the job's actual parameters. Still a boot panic, same as an unknown job name — a typo'd `?repare=true` must not leave a migration importing forever in discovery mode. * `JobSummary.parameters` carries it to the panel, whose `supportsDeep` was a hardcoded name allowlist (`consistency_batch || backend_consistency`). A job gaining a deep mode needed a frontend release; one losing it left a button that silently did nothing. The menu now renders from the declaration, so a newly-declared boolean appears with no frontend change. Three consistency tenants were hand-rolling persist-on-fresh / restore-on-resume for their own flag, under the same `params` key the engine already used. Deleted — they read `args.get_bool(…)` now. Fresh runs also filter to the declaration. `consistency_batch` forwards its args verbatim to sub-jobs, so a tenant's `params` row could grow `deep` with no deep mode, and the run-detail view would claim a mode the job never had. Two things found while wiring it, both worth knowing: `RecoverableAdapter` bridges the two traits, and `parameters` has to be forwarded there or the registry sees `&[]`. Both traits have defaults, so omitting it compiled cleanly — and the trigger endpoint then rejected `?repair=true` on the very jobs that declare it, with OXICLOUD_STARTUP_JOBS panicking at boot. Now covered by `adapter_forwards_job_metadata_from_inner_handler`. `TriggerJobQuery` was briefly a newtype over the map. `serde_urlencoded` cannot deserialize a newtype struct at the top level, so axum's `Query` rejected EVERY trigger with a 400 — even one with no query string — before the handler ran. It reads exactly like the new validation rejecting something, which sent the first diagnosis to the wrong layer. Now covered by `trigger_query_extracts_from_every_url_shape`. Wire names are a compatibility surface: `params` rows are keyed by them and the panel switches on them, so a rename breaks existing run history the same way renaming a `Mutates` variant does. The JSON shape is pinned in `snapshot_carries_job_metadata`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3873,18 +3873,33 @@ impl crate::infrastructure::scheduler::JobHandler for DedupService {
|
||||
/// the freed disk. GC returning `(0, 0)` is normal — it means trash
|
||||
/// cleanup already reaped everything.
|
||||
///
|
||||
/// `args.force = true` skips the orphan grace window
|
||||
/// `force = true` skips the orphan grace window
|
||||
/// (`garbage_collect_force` — grace_secs = 0). Same semantic as
|
||||
/// `POST /api/admin/jobs/dedup_gc/trigger?force=true`. Unsafe
|
||||
/// under concurrent uploads: only reachable through the admin
|
||||
/// endpoint and only intentionally used by tests + operator
|
||||
/// diagnostic sessions.
|
||||
fn parameters(&self) -> &'static [crate::infrastructure::scheduler::JobParam] {
|
||||
use crate::infrastructure::scheduler::JobParam;
|
||||
// A named `const` rather than a bare `&[…]` literal: implicit
|
||||
// const promotion does not cover `const fn` calls, so the
|
||||
// literal would be a temporary. Same shape in every job.
|
||||
const PARAMS: &[JobParam] = &[JobParam::boolean(
|
||||
"force",
|
||||
false,
|
||||
"Skip the orphan grace window. Unsafe under concurrent \
|
||||
uploads — it reopens the TOCTOU window the grace closes.",
|
||||
)];
|
||||
PARAMS
|
||||
}
|
||||
|
||||
async fn run(
|
||||
&self,
|
||||
args: &crate::infrastructure::scheduler::JobRunArgs,
|
||||
) -> crate::infrastructure::scheduler::JobOutcome {
|
||||
use crate::infrastructure::scheduler::JobOutcome;
|
||||
let result = if args.force {
|
||||
let force = args.get_bool("force");
|
||||
let result = if force {
|
||||
self.garbage_collect_force().await
|
||||
} else {
|
||||
self.garbage_collect().await
|
||||
@@ -3892,7 +3907,7 @@ impl crate::infrastructure::scheduler::JobHandler for DedupService {
|
||||
match result {
|
||||
Ok((items, bytes)) => JobOutcome::ok_with(
|
||||
items,
|
||||
serde_json::json!({ "bytes_reclaimed": bytes, "forced": args.force }),
|
||||
serde_json::json!({ "bytes_reclaimed": bytes, "forced": force }),
|
||||
),
|
||||
Err(e) => JobOutcome::err(format!("dedup GC failed: {e}")),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user