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:
+55
-60
@@ -2,8 +2,6 @@ use std::env;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::infrastructure::scheduler::JobRunArgs;
|
||||
|
||||
/// Cache configuration
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CacheConfig {
|
||||
@@ -2337,8 +2335,10 @@ pub struct GrantCleanupConfig {
|
||||
pub struct StartupJob {
|
||||
/// Registered job name — must match `JobHandler::name`.
|
||||
pub name: String,
|
||||
/// Forwarded verbatim to `JobRegistry::trigger`.
|
||||
pub args: JobRunArgs,
|
||||
/// Untyped `key=value` pairs, parsed against the job's declared
|
||||
/// parameters at dispatch. See [`parse_startup_job`] for why the
|
||||
/// typing cannot happen here.
|
||||
pub raw_params: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// Parse one `OXICLOUD_STARTUP_JOBS` entry: `name`, or
|
||||
@@ -2364,39 +2364,24 @@ fn parse_startup_job(raw: &str) -> Result<StartupJob, String> {
|
||||
return Err("empty job name".to_string());
|
||||
}
|
||||
|
||||
let mut job = StartupJob {
|
||||
name: name.to_string(),
|
||||
args: JobRunArgs::default(),
|
||||
};
|
||||
|
||||
// Raw pairs only. Config is parsed long before the job registry
|
||||
// exists, so the declaration is not reachable here — typing and
|
||||
// validation happen at dispatch (`di.rs`), which is also where an
|
||||
// unknown job NAME is already caught with a boot panic. Both
|
||||
// failures therefore surface at the same moment and in the same
|
||||
// shape, rather than one at parse and one at dispatch.
|
||||
let mut raw_params = Vec::new();
|
||||
for pair in query.split('&').filter(|p| !p.is_empty()) {
|
||||
let (key, value) = pair
|
||||
.split_once('=')
|
||||
.ok_or_else(|| format!("`{pair}` is not key=value (job `{name}`)"))?;
|
||||
// Booleans accept only `true`/`false` — the same rule the HTTP
|
||||
// trigger enforces, so a value that works in one place works in
|
||||
// the other. See memory `bug_axum_query_bool_only_accepts_true_false`.
|
||||
let as_bool = || match value {
|
||||
"true" => Ok(true),
|
||||
"false" => Ok(false),
|
||||
other => Err(format!(
|
||||
"`{key}={other}` on job `{name}`: expected true or false"
|
||||
)),
|
||||
};
|
||||
match key {
|
||||
"force" => job.args.force = as_bool()?,
|
||||
"deep" => job.args.deep = as_bool()?,
|
||||
"repair" => job.args.repair = as_bool()?,
|
||||
"storage" => job.args.storage = Some(value.to_string()),
|
||||
other => {
|
||||
return Err(format!(
|
||||
"unknown flag `{other}` on job `{name}`: expected force, deep, repair \
|
||||
or storage"
|
||||
));
|
||||
}
|
||||
}
|
||||
raw_params.push((key.to_string(), value.to_string()));
|
||||
}
|
||||
Ok(job)
|
||||
|
||||
Ok(StartupJob {
|
||||
name: name.to_string(),
|
||||
raw_params,
|
||||
})
|
||||
}
|
||||
|
||||
/// What runs at boot when `OXICLOUD_STARTUP_JOBS` is unset.
|
||||
@@ -4032,31 +4017,39 @@ mod tests {
|
||||
assert_eq!(rl.delta_upload_window_secs, 60);
|
||||
}
|
||||
|
||||
/// Helper: the raw value for `key`, or `None`.
|
||||
fn raw<'a>(job: &'a StartupJob, key: &str) -> Option<&'a str> {
|
||||
job.raw_params
|
||||
.iter()
|
||||
.find(|(k, _)| k == key)
|
||||
.map(|(_, v)| v.as_str())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_job_parses_name_and_flags() {
|
||||
fn startup_job_parses_name_and_params() {
|
||||
let jobs = parse_startup_jobs(
|
||||
"thumb_derived_import?repair=true, thumb_attached_import ,blobs_consistency?deep=true&force=false",
|
||||
);
|
||||
assert_eq!(jobs.len(), 3);
|
||||
|
||||
assert_eq!(jobs[0].name, "thumb_derived_import");
|
||||
assert!(jobs[0].args.repair);
|
||||
assert!(!jobs[0].args.deep);
|
||||
assert_eq!(raw(&jobs[0], "repair"), Some("true"));
|
||||
|
||||
// Bare name → all flags default off, which is the discovery-only
|
||||
// run. Naming a migration job without `repair` imports and stops.
|
||||
// Bare name → no params at all, so every declared default
|
||||
// applies. Naming a migration job without `repair` imports and
|
||||
// stops, which is the discovery-only run.
|
||||
assert_eq!(jobs[1].name, "thumb_attached_import");
|
||||
assert!(!jobs[1].args.repair);
|
||||
assert!(jobs[1].raw_params.is_empty());
|
||||
|
||||
assert!(jobs[2].args.deep);
|
||||
assert!(!jobs[2].args.force);
|
||||
assert_eq!(raw(&jobs[2], "deep"), Some("true"));
|
||||
assert_eq!(raw(&jobs[2], "force"), Some("false"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_job_accepts_storage_scope() {
|
||||
let jobs = parse_startup_jobs("backend_consistency?storage=s3_prod&deep=true");
|
||||
assert_eq!(jobs[0].args.storage.as_deref(), Some("s3_prod"));
|
||||
assert!(jobs[0].args.deep);
|
||||
assert_eq!(raw(&jobs[0], "storage"), Some("s3_prod"));
|
||||
assert_eq!(raw(&jobs[0], "deep"), Some("true"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4087,27 +4080,29 @@ mod tests {
|
||||
"transcode_import"
|
||||
]
|
||||
);
|
||||
assert!(jobs.iter().all(|j| j.args.repair));
|
||||
assert!(jobs.iter().all(|j| !j.args.deep && !j.args.force));
|
||||
assert!(jobs.iter().all(|j| raw(j, "repair") == Some("true")));
|
||||
assert!(
|
||||
jobs.iter()
|
||||
.all(|j| raw(j, "deep").is_none() && raw(j, "force").is_none())
|
||||
);
|
||||
}
|
||||
|
||||
/// A misspelled flag must not parse. Silently ignoring `repare=true`
|
||||
/// leaves the job in discovery-only mode while the operator believes
|
||||
/// the tier is draining — a failure that surfaces months later as
|
||||
/// "the migration never finished", with nothing pointing at the
|
||||
/// config line.
|
||||
/// A misspelled or non-boolean parameter must still be fatal at boot
|
||||
/// — silently ignoring `repare=true` leaves the job in discovery-only
|
||||
/// mode while the operator believes the tier is draining, a failure
|
||||
/// that surfaces months later as "the migration never finished" with
|
||||
/// nothing pointing at the config line.
|
||||
///
|
||||
/// **That check moved rather than went away.** It now runs in
|
||||
/// `di.rs`, against the job's declared parameters, because only there
|
||||
/// is the registry built — which also means the error names the
|
||||
/// job's REAL parameters instead of a hardcoded list. Parsing here
|
||||
/// deliberately accepts any `key=value`; see
|
||||
/// `JobRunArgs::from_declared` and its tests for the rejection.
|
||||
#[test]
|
||||
#[should_panic(expected = "unknown flag `repare`")]
|
||||
fn startup_job_rejects_a_misspelled_flag() {
|
||||
parse_startup_jobs("thumb_derived_import?repare=true");
|
||||
}
|
||||
|
||||
/// Booleans take only true/false — the same rule the HTTP trigger
|
||||
/// enforces, so a value that works in one place works in the other.
|
||||
#[test]
|
||||
#[should_panic(expected = "expected true or false")]
|
||||
fn startup_job_rejects_a_non_boolean_flag_value() {
|
||||
parse_startup_jobs("thumb_derived_import?repair=yes");
|
||||
fn startup_job_defers_parameter_validation_to_dispatch() {
|
||||
let jobs = parse_startup_jobs("thumb_derived_import?repare=true");
|
||||
assert_eq!(raw(&jobs[0], "repare"), Some("true"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+35
-18
@@ -2922,35 +2922,54 @@ impl AppServiceFactory {
|
||||
if !self.config.startup_jobs.is_empty() {
|
||||
let mut planned = Vec::with_capacity(self.config.startup_jobs.len());
|
||||
for job in &self.config.startup_jobs {
|
||||
if app_state.core.job_registry.get(&job.name).await.is_none() {
|
||||
let Some(declared) = app_state.core.job_registry.parameters_of(&job.name).await
|
||||
else {
|
||||
panic!(
|
||||
"OXICLOUD_STARTUP_JOBS names `{}`, which is not a registered job. \
|
||||
Check the spelling against GET /api/admin/jobs.",
|
||||
job.name
|
||||
);
|
||||
}
|
||||
planned.push(job.clone());
|
||||
};
|
||||
// Same fail-fast rule as the unknown-name panic above, and
|
||||
// for the same reason: a typo'd `?repare=true` would leave
|
||||
// a migration importing forever in discovery mode while the
|
||||
// operator believed the tier was draining. The declaration
|
||||
// is only reachable here, after the registry is built —
|
||||
// config parsing kept the pairs untyped.
|
||||
let args = crate::infrastructure::scheduler::JobRunArgs::from_declared(
|
||||
declared,
|
||||
job.raw_params.iter().map(|(k, v)| (k.as_str(), v.as_str())),
|
||||
)
|
||||
.unwrap_or_else(|e| {
|
||||
panic!("OXICLOUD_STARTUP_JOBS entry `{}`: {e}", job.name);
|
||||
});
|
||||
planned.push((job.name.clone(), args));
|
||||
}
|
||||
|
||||
let registry = app_state.core.job_registry.clone();
|
||||
tokio::spawn(async move {
|
||||
for job in planned {
|
||||
for (job_name, args) in planned {
|
||||
// Audited, not merely logged: a startup job may delete
|
||||
// files, and "who asked for this" must be answerable
|
||||
// afterwards. The answer is the configuration, which is
|
||||
// exactly what this line records.
|
||||
//
|
||||
// Rendered from the parsed args rather than naming each
|
||||
// parameter, so a job growing one cannot end up
|
||||
// dispatched with something the audit trail omits.
|
||||
let params_desc = args
|
||||
.iter()
|
||||
.filter_map(|(k, v)| v.to_param_string().map(|s| format!("{k}={s}")))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "job.startup_trigger",
|
||||
job = %job.name,
|
||||
force = job.args.force,
|
||||
deep = job.args.deep,
|
||||
repair = job.args.repair,
|
||||
storage = ?job.args.storage,
|
||||
"👮🏻♂️ dispatching `{}` from OXICLOUD_STARTUP_JOBS",
|
||||
job.name,
|
||||
job = %job_name,
|
||||
params = %params_desc,
|
||||
"👮🏻♂️ dispatching `{job_name}` from OXICLOUD_STARTUP_JOBS ({params_desc})",
|
||||
);
|
||||
match registry.trigger(&job.name, &job.args).await {
|
||||
match registry.trigger(&job_name, &args).await {
|
||||
// Debug, not info. The engine already logs every
|
||||
// dispatch as `job.run` with the outcome and timing —
|
||||
// that is the point of routing through `trigger`
|
||||
@@ -2962,10 +2981,9 @@ impl AppServiceFactory {
|
||||
Some(outcome) => tracing::debug!(
|
||||
target: "oxicloud::scheduler",
|
||||
event = "job.startup_completed",
|
||||
job = %job.name,
|
||||
job = %job_name,
|
||||
outcome = outcome.kind(),
|
||||
"startup job `{}` finished ({})",
|
||||
job.name,
|
||||
"startup job `{job_name}` finished ({})",
|
||||
outcome.kind(),
|
||||
),
|
||||
// Unreachable — the name was resolved above, and
|
||||
@@ -2974,10 +2992,9 @@ impl AppServiceFactory {
|
||||
None => tracing::error!(
|
||||
target: "oxicloud::scheduler",
|
||||
event = "job.startup_vanished",
|
||||
job = %job.name,
|
||||
"startup job `{}` disappeared from the registry between \
|
||||
job = %job_name,
|
||||
"startup job `{job_name}` disappeared from the registry between \
|
||||
validation and dispatch",
|
||||
job.name,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user