Skip to content

Commit 19246af

Browse files
committed
fix(spurctld): make WAL authoritative over config partition seeds, order QoS default before partition ACL
Address review findings on the partition CRUD / reconfigure changes: - submit_job resolved the partition QoS ACL before applying the default QoS, so a user with no explicit --qos whose association/cluster default is in the partition's allow_qos was wrongly rejected with "a QoS is required". Resolve the default first, then validate. - PartitionCreate apply was first-writer-wins, so a config-seeded partition blocked a replayed runtime partition of the same name on restart: a runtime edit later codified into spur.conf with different values silently reverted, and two controllers with differing confs replayed the same log into divergent tables. Track config-seeded names and let a replayed WAL entry overwrite the seed (WAL authoritative); a genuine duplicate create stays first-writer-wins. - Snapshot restore used partitions.is_empty() as the legacy-snapshot signal, conflating a pre-partition snapshot with an authoritative empty set (reconfigure can delete them all). A follower then reseeded from its own config and diverged after InstallSnapshot. Make the field Option<Vec<Partition>>: None (field absent) falls back to the config baseline, Some(_) installs verbatim. - Docs/CLI: clarify that reconfigure applies controller-side hooks live but does not reach compute nodes (node-side prolog/epilog, device registry, memlock stay restart-only), and that the scheduler loop cadence is restart-only while only complete_wait_secs/resv_overrun_minutes reload live.
1 parent 42af3ef commit 19246af

3 files changed

Lines changed: 199 additions & 41 deletions

File tree

crates/spur-cli/src/scontrol.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -223,8 +223,9 @@ pub enum ScontrolCommand {
223223
name: String,
224224
},
225225
/// Re-read spur.conf and apply it live on the leader (partitions, nodes,
226-
/// licenses, hooks, scheduler tunables, etc.). Ports/DB/raft/jwt_key need a
227-
/// restart; followers converge on restart.
226+
/// licenses, controller hooks, complete_wait_secs/resv_overrun_minutes, etc.).
227+
/// Ports/DB/raft/jwt_key, the scheduler loop cadence, and node-side settings
228+
/// need a restart; followers converge on restart.
228229
Reconfigure,
229230
/// Create a reservation
230231
#[command(name = "create-reservation")]

crates/spurctld/src/cluster.rs

Lines changed: 191 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,11 @@ pub struct ClusterManager {
305305
/// Names of partitions that were runtime-deleted. Used to suppress config-file
306306
/// partitions with the same name from re-appearing on restart.
307307
deleted_partition_names: RwLock<HashSet<String>>,
308+
/// Partition names seeded from config before WAL replay. A replayed
309+
/// `PartitionCreate` for one overwrites the seed (WAL is authoritative);
310+
/// once overridden the name is removed, so later duplicates stay
311+
/// first-writer-wins. Runtime-only, never persisted.
312+
config_seeded_partitions: RwLock<HashSet<String>>,
308313
next_job_id: AtomicU32,
309314
reservations: RwLock<Vec<Reservation>>,
310315
steps: RwLock<HashMap<(JobId, u32), JobStep>>,
@@ -354,6 +359,8 @@ impl ClusterManager {
354359
config_path: Option<PathBuf>,
355360
) -> anyhow::Result<Self> {
356361
let partitions = config.build_partitions();
362+
let config_seeded_partitions: HashSet<String> =
363+
partitions.iter().map(|p| p.name.clone()).collect();
357364
let license_pool = config.licenses.clone();
358365
let burst_buffer_total_gb = config.burst_buffer.total_gb;
359366
let fairshare_cache = Arc::new(FairshareCache::new());
@@ -370,6 +377,7 @@ impl ClusterManager {
370377
nodes: RwLock::new(HashMap::new()),
371378
partitions: RwLock::new(partitions),
372379
deleted_partition_names: RwLock::new(HashSet::new()),
380+
config_seeded_partitions: RwLock::new(config_seeded_partitions),
373381
reservations: RwLock::new(Vec::new()),
374382
steps: RwLock::new(HashMap::new()),
375383
next_job_id: AtomicU32::new(first_job_id),
@@ -404,17 +412,19 @@ impl ClusterManager {
404412
apply_default_time_limit(&mut spec, &self.partitions.read());
405413
apply_default_account(&mut spec, &self.association_cache);
406414
validate_user_account(&spec, &self.association_cache)?;
407-
self.validate_partition(&spec)?;
408-
let mpi = spec.mpi.as_deref().unwrap_or(spur_core::mpi::MPI_NONE);
409-
spur_core::mpi::validate_single_node_pmix(mpi, spec.num_nodes)
410-
.map_err(SubmitError::invalid)?;
411415
let config = self.config();
416+
// Default QoS must resolve before the partition ACL, or `allow_qos` sees
417+
// an empty QoS and wrongly rejects a user's inherited default.
412418
apply_default_qos(
413419
&mut spec,
414420
&self.association_cache,
415421
&self.qos_cache,
416422
&config.accounting,
417423
)?;
424+
self.validate_partition(&spec)?;
425+
let mpi = spec.mpi.as_deref().unwrap_or(spur_core::mpi::MPI_NONE);
426+
spur_core::mpi::validate_single_node_pmix(mpi, spec.num_nodes)
427+
.map_err(SubmitError::invalid)?;
418428

419429
// Checked after defaults are applied so we measure the final spec.
420430
// Array expansion only adds bounded integer metadata per task, so a
@@ -4482,22 +4492,42 @@ impl ClusterManager {
44824492
self.deleted_partition_names.write().remove(&partition.name);
44834493
{
44844494
let mut partitions = self.partitions.write();
4485-
if partitions.iter().any(|p| p.name == partition.name) {
4486-
warn!(
4487-
name = %partition.name,
4488-
"duplicate partition create in WAL apply, ignoring"
4489-
);
4490-
} else {
4491-
// Promote exactly one default: clear all others when
4492-
// the new partition is created as the default.
4493-
if partition.is_default {
4494-
for p in partitions.iter_mut() {
4495-
p.is_default = false;
4495+
let existing = partitions.iter().position(|p| p.name == partition.name);
4496+
// Seeded name => the existing entry is only the pre-replay
4497+
// config seed, so the WAL entry overrides it; else duplicate.
4498+
let seeded = self
4499+
.config_seeded_partitions
4500+
.write()
4501+
.remove(&partition.name);
4502+
match existing {
4503+
Some(idx) if seeded => {
4504+
if partition.is_default {
4505+
for p in partitions.iter_mut() {
4506+
p.is_default = false;
4507+
}
4508+
}
4509+
partitions[idx] = partition.clone();
4510+
response.partition_created = true;
4511+
info!(name = %partition.name, "partition restored from WAL over config seed");
4512+
}
4513+
Some(_) => {
4514+
warn!(
4515+
name = %partition.name,
4516+
"duplicate partition create in WAL apply, ignoring"
4517+
);
4518+
}
4519+
None => {
4520+
// Promote exactly one default: clear all others when
4521+
// the new partition is created as the default.
4522+
if partition.is_default {
4523+
for p in partitions.iter_mut() {
4524+
p.is_default = false;
4525+
}
44964526
}
4527+
partitions.push(partition.clone());
4528+
response.partition_created = true;
4529+
info!(name = %partition.name, "partition created");
44974530
}
4498-
partitions.push(partition.clone());
4499-
response.partition_created = true;
4500-
info!(name = %partition.name, "partition created");
45014531
}
45024532
}
45034533
// Node-to-partition membership is derived from the partition
@@ -4722,11 +4752,12 @@ struct ClusterSnapshot {
47224752
jobs: Vec<Job>,
47234753
nodes: Vec<Node>,
47244754
reservations: Vec<Reservation>,
4725-
/// Runtime-created/modified partitions. On restore these overlay the
4726-
/// config-file baseline; names in `deleted_partition_names` are skipped
4727-
/// from the baseline entirely.
4755+
/// The leader's authoritative partition table. `None` = pre-partition-support
4756+
/// snapshot (field absent) → restore falls back to the config baseline.
4757+
/// `Some(_)`, empty included, is installed verbatim so a leader with zero
4758+
/// partitions is not reseeded from a follower's local config.
47284759
#[serde(default)]
4729-
partitions: Vec<Partition>,
4760+
partitions: Option<Vec<Partition>>,
47304761
/// Names of partitions deleted at runtime. Suppresses config-file
47314762
/// partitions with the same name from re-seeding on restart.
47324763
#[serde(default)]
@@ -4803,7 +4834,7 @@ impl StateMachineApply for ClusterManager {
48034834
jobs: self.jobs.read().values().cloned().collect(),
48044835
nodes: self.nodes.read().values().cloned().collect(),
48054836
reservations: self.reservations.read().clone(),
4806-
partitions: self.partitions.read().clone(),
4837+
partitions: Some(self.partitions.read().clone()),
48074838
deleted_partition_names: self.deleted_partition_names.read().clone(),
48084839
steps: self.steps.read().values().cloned().collect(),
48094840
license_pool: self.license_pool.read().clone(),
@@ -4836,24 +4867,23 @@ impl StateMachineApply for ClusterManager {
48364867
// Restore tombstone set first — used below to filter the config baseline.
48374868
*self.deleted_partition_names.write() = snap.deleted_partition_names.clone();
48384869

4839-
// Restore the partition table wholesale from the snapshot, like every
4840-
// other collection here. snap.partitions is the leader's complete
4841-
// authoritative set (snapshot_state clones the full live vec), so a
4842-
// stale in-memory partition can't survive install and local config
4843-
// can't reintroduce one the leader doesn't have. Only fall back to the
4844-
// config baseline for a pre-partition-snapshot snapshot, where the
4845-
// serde-default leaves snap.partitions empty and there is nothing
4846-
// authoritative to restore.
4870+
// `snap.partitions` is the leader's authoritative set, installed
4871+
// wholesale. Only a pre-partition snapshot (`None`) falls back to the
4872+
// config baseline; an authoritative empty set installs verbatim.
48474873
{
48484874
let mut partitions = self.partitions.write();
4849-
*partitions = if snap.partitions.is_empty() {
4850-
let mut base = self.config().build_partitions();
4851-
base.retain(|p| !snap.deleted_partition_names.contains(&p.name));
4852-
base
4853-
} else {
4854-
snap.partitions
4875+
*partitions = match snap.partitions {
4876+
Some(p) => p,
4877+
None => {
4878+
let mut base = self.config().build_partitions();
4879+
base.retain(|p| !snap.deleted_partition_names.contains(&p.name));
4880+
base
4881+
}
48554882
};
48564883
}
4884+
// The installed table is authoritative; no config seed remains for the
4885+
// tail log to override.
4886+
self.config_seeded_partitions.write().clear();
48574887

48584888
let mut steps = self.steps.write();
48594889
steps.clear();
@@ -12053,6 +12083,32 @@ mod tests {
1205312083
);
1205412084
}
1205512085

12086+
// An authoritative empty set (leader deleted them all) must install verbatim,
12087+
// not reseed from local config — the case `is_empty()` conflated with legacy.
12088+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
12089+
async fn restore_from_authoritative_empty_snapshot_wipes_partitions() {
12090+
let dir = TempDir::new().unwrap();
12091+
let cm = test_cluster(&dir).await;
12092+
assert!(
12093+
!cm.get_partitions().is_empty(),
12094+
"test config must seed a partition"
12095+
);
12096+
12097+
// A leader snapshot with an explicit empty (Some(vec![])) partition set.
12098+
let mut snap: serde_json::Value =
12099+
serde_json::from_slice(&cm.snapshot_state().unwrap()).unwrap();
12100+
snap.as_object_mut()
12101+
.unwrap()
12102+
.insert("partitions".into(), serde_json::json!([]));
12103+
let data = serde_json::to_vec(&snap).unwrap();
12104+
12105+
cm.restore_from_snapshot(&data).unwrap();
12106+
assert!(
12107+
cm.get_partitions().is_empty(),
12108+
"authoritative empty set must wipe local partitions, not reseed from config"
12109+
);
12110+
}
12111+
1205612112
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1205712113
async fn restore_from_snapshot_rejects_corrupt_data() {
1205812114
let dir = TempDir::new().unwrap();
@@ -14465,7 +14521,7 @@ mod tests {
1446514521
jobs: Vec::new(),
1446614522
nodes: vec![stale],
1446714523
reservations: Vec::new(),
14468-
partitions: Vec::new(),
14524+
partitions: None,
1446914525
deleted_partition_names: HashSet::new(),
1447014526
steps: Vec::new(),
1447114527
license_pool: HashMap::new(),
@@ -15147,6 +15203,64 @@ mod tests {
1514715203
);
1514815204
}
1514915205

15206+
// A config-seeded partition must lose to a replayed WAL PartitionCreate of
15207+
// the same name, or a runtime edit later codified into spur.conf reverts on
15208+
// restart and two controllers with differing confs diverge.
15209+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
15210+
async fn apply_partition_create_overrides_config_seed() {
15211+
let dir = TempDir::new().unwrap();
15212+
// Default test config seeds a partition named "default".
15213+
let cm = test_cluster(&dir).await;
15214+
assert!(
15215+
cm.config_seeded_partitions.read().contains("default"),
15216+
"precondition: 'default' is config-seeded"
15217+
);
15218+
15219+
let runtime = spur_core::partition::Partition {
15220+
name: "default".into(),
15221+
state: spur_core::partition::PartitionState::Up,
15222+
is_default: true,
15223+
nodes: "node[01-09]".into(),
15224+
max_time_minutes: Some(720),
15225+
allow_accounts: vec!["runtime-team".into()],
15226+
priority_tier: 7,
15227+
..Default::default()
15228+
};
15229+
cm.apply_operation(&WalOperation::PartitionCreate {
15230+
partition: runtime.clone(),
15231+
});
15232+
15233+
let parts = cm.get_partitions();
15234+
let def = parts.iter().find(|p| p.name == "default").unwrap();
15235+
assert_eq!(def.nodes, "node[01-09]", "WAL value must overwrite seed");
15236+
assert_eq!(def.max_time_minutes, Some(720));
15237+
assert_eq!(def.priority_tier, 7);
15238+
assert_eq!(
15239+
parts.iter().filter(|p| p.name == "default").count(),
15240+
1,
15241+
"override must not add a second entry"
15242+
);
15243+
assert!(
15244+
!cm.config_seeded_partitions.read().contains("default"),
15245+
"seed marker must be cleared once the WAL has overridden it"
15246+
);
15247+
15248+
// The seed override is one-shot: a further duplicate is a genuine
15249+
// create race and stays first-writer-wins.
15250+
let mut evil = runtime.clone();
15251+
evil.priority_tier = 99;
15252+
cm.apply_operation(&WalOperation::PartitionCreate { partition: evil });
15253+
let def = cm
15254+
.get_partitions()
15255+
.into_iter()
15256+
.find(|p| p.name == "default")
15257+
.unwrap();
15258+
assert_eq!(
15259+
def.priority_tier, 7,
15260+
"post-override duplicate must be ignored (first-writer-wins)"
15261+
);
15262+
}
15263+
1515015264
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1515115265
async fn apply_partition_update_unknown_is_a_noop() {
1515215266
let dir = TempDir::new().unwrap();
@@ -15552,4 +15666,43 @@ mod tests {
1555215666
"empty allow_qos must not restrict any QoS"
1555315667
);
1555415668
}
15669+
15670+
// Full submit path: a user with no explicit `-q` whose association default
15671+
// QoS is in the partition's allow_qos must be admitted. The tests above call
15672+
// validate_partition with an explicit qos, so none covers this ordering.
15673+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
15674+
async fn submit_admits_association_default_qos_on_allow_qos_partition() {
15675+
let dir = TempDir::new().unwrap();
15676+
let cm = test_cluster(&dir).await;
15677+
cm.qos_cache().insert(Qos {
15678+
name: "premium".into(),
15679+
..Default::default()
15680+
});
15681+
cm.association_cache()
15682+
.insert_default_qos("testuser", "research", "premium");
15683+
15684+
let part = spur_core::partition::Partition {
15685+
name: "restricted".into(),
15686+
nodes: "ALL".into(),
15687+
allow_qos: vec!["premium".into()],
15688+
..Default::default()
15689+
};
15690+
cm.create_partition(part).unwrap();
15691+
wait_for("restricted created", || {
15692+
cm.get_partitions().iter().any(|p| p.name == "restricted")
15693+
});
15694+
15695+
let mut spec = basic_spec("inherits-default");
15696+
spec.account = Some("research".into());
15697+
spec.partition = Some("restricted".into());
15698+
spec.qos = None;
15699+
let id = cm
15700+
.submit_job(spec)
15701+
.expect("association default QoS must satisfy the partition allow_qos");
15702+
assert_eq!(
15703+
cm.get_job(id).unwrap().spec.qos.as_deref(),
15704+
Some("premium"),
15705+
"the resolved default QoS must be recorded on the job"
15706+
);
15707+
}
1555515708
}

docs/deployment/partitioning.rst

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,9 +97,13 @@ runtime-only changes not reflected in the file are overwritten.
9797

9898
**Applied live** (no restart): ``[[partitions]]`` (created, updated, or deleted
9999
to match the file), ``[[nodes]]`` features and weight, ``licenses``,
100-
``burst_buffer``, ``[hooks]``, ``[notifications]``, ``[federation]``,
100+
``burst_buffer``, controller-side ``[hooks]`` (``prolog_slurmctld``,
101+
``epilog_slurmctld``), ``[notifications]``, ``[federation]``,
101102
``[power]`` suspend/resume commands, ``[admission]`` mode, and the
102103
``[scheduler]`` tunables ``complete_wait_secs`` and ``resv_overrun_minutes``.
104+
Node-side hooks (the per-node prolog/epilog run by ``spurd``), the device
105+
registry, and memlock are read by the node agent at its own startup;
106+
``reconfigure`` does not reach compute nodes, so those need a ``spurd`` restart.
103107

104108
**Restart-only**: settings baked in when the daemon starts — listen addresses
105109
and ports (``[controller]``, ``[metrics]``, ``[rest_api]``), the accounting

0 commit comments

Comments
 (0)