@@ -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}
0 commit comments