1use std::collections::{BTreeSet, HashMap, HashSet};
17
18use serde::{Deserialize, Serialize};
19
20use crate::certificate::ResidualCertificate;
21use crate::workgraph::{WorkGraphRevision, WorkNode, WorkNodeState};
22
23#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
26pub enum Resource {
27 File(String),
28 Interface(String),
29 Manifest(String),
30 Lockfile(String),
31 Migration(String),
32 TestFixture(String),
33 Toolchain(String),
34 Scoped {
36 family: String,
37 key: String,
38 },
39 Capability(String),
41 RiskBudget(String),
43 FreshIdAllocator,
45 LedgerRoot,
47 WorkGraph,
49 ProviderRateLimit(String),
53 OpaqueWorkspace,
57}
58
59#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
61pub struct Footprint {
62 pub reads: BTreeSet<Resource>,
63 pub writes: BTreeSet<Resource>,
64}
65
66impl Footprint {
67 pub fn new() -> Self {
68 Self::default()
69 }
70
71 pub fn read(mut self, r: Resource) -> Self {
72 self.reads.insert(r);
73 self
74 }
75
76 pub fn write(mut self, r: Resource) -> Self {
77 self.writes.insert(r);
78 self
79 }
80
81 pub fn commutes_with(&self, other: &Footprint) -> bool {
84 let opaque = |f: &Footprint| f.writes.contains(&Resource::OpaqueWorkspace);
87 let touches = |f: &Footprint| !f.writes.is_empty();
88 if (opaque(self) && touches(other)) || (opaque(other) && touches(self)) {
89 return false;
90 }
91 self.writes.is_disjoint(&other.reads)
92 && other.writes.is_disjoint(&self.reads)
93 && self.writes.is_disjoint(&other.writes)
94 }
95
96 pub fn conflicts_with(&self, other: &Footprint) -> bool {
97 !self.commutes_with(other)
98 }
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
103#[serde(rename_all = "snake_case")]
104pub enum LeaseKind {
105 GraphWrite,
107 Toolchain,
109 Resource,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115pub struct ExecutionLease {
116 pub lease_id: String,
117 pub holder_work_id: String,
118 pub kind: LeaseKind,
119 pub scope: Resource,
120}
121
122#[derive(Debug, Clone, Default)]
124pub struct LeaseTable {
125 held: Vec<ExecutionLease>,
126}
127
128impl LeaseTable {
129 pub fn new() -> Self {
130 Self::default()
131 }
132
133 pub fn is_available(&self, scope: &Resource) -> bool {
135 !self.held.iter().any(|l| &l.scope == scope)
136 }
137
138 pub fn acquire(&mut self, holder: &str, kind: LeaseKind, scope: Resource) -> Option<String> {
140 if !self.is_available(&scope) {
141 return None;
142 }
143 let lease_id = uuid::Uuid::new_v4().to_string();
144 self.held.push(ExecutionLease {
145 lease_id: lease_id.clone(),
146 holder_work_id: holder.to_string(),
147 kind,
148 scope,
149 });
150 Some(lease_id)
151 }
152
153 pub fn release(&mut self, lease_id: &str) {
154 self.held.retain(|l| l.lease_id != lease_id);
155 }
156
157 pub fn held_count(&self) -> usize {
158 self.held.len()
159 }
160}
161
162#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
164#[serde(tag = "action", rename_all = "snake_case")]
165pub enum RepairAction {
166 RetryNode {
167 node_id: String,
168 generation: u32,
169 },
170 ExpandScope {
171 node_id: String,
172 generation: u32,
173 added_paths: Vec<String>,
174 },
175 SplitNode {
176 node_id: String,
177 generation: u32,
178 child_goals: Vec<String>,
179 },
180 InsertInterfaceNode {
181 boundary: String,
182 },
183 AddNode {
184 goal: String,
185 reason: String,
186 },
187 RetireNode {
188 node_id: String,
189 generation: u32,
190 reason: String,
191 },
192 ReplanSubgraph {
193 root: String,
194 affected: Vec<String>,
195 },
196 StopNode {
197 node_id: String,
198 generation: u32,
199 certificate_id: String,
200 },
201}
202
203#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
206#[serde(tag = "effect", rename_all = "snake_case")]
207pub enum SchedulerEffect {
208 CommitNode {
209 node_id: String,
210 generation: u32,
211 },
212 RequeueNode {
213 node_id: String,
214 generation: u32,
215 reason: String,
216 },
217 ApplyGraphRevision {
218 revision_id: String,
219 },
220 SpawnWork {
221 work_id: String,
222 },
223 CancelWork {
224 work_id: String,
225 reason: String,
226 },
227 RequestApproval {
228 proposal_id: String,
229 },
230 StopWithCertificate {
231 certificate_id: String,
232 },
233}
234
235pub fn repair_to_effects(action: &RepairAction) -> Vec<SchedulerEffect> {
239 match action {
240 RepairAction::RetryNode {
241 node_id,
242 generation,
243 } => vec![SchedulerEffect::RequeueNode {
244 node_id: node_id.clone(),
245 generation: *generation,
246 reason: "retry".into(),
247 }],
248 RepairAction::ExpandScope {
249 node_id,
250 generation,
251 ..
252 } => vec![SchedulerEffect::RequeueNode {
253 node_id: node_id.clone(),
254 generation: generation + 1,
255 reason: "scope expanded".into(),
256 }],
257 RepairAction::SplitNode { child_goals, .. } => child_goals
258 .iter()
259 .map(|_| SchedulerEffect::SpawnWork {
260 work_id: uuid::Uuid::new_v4().to_string(),
261 })
262 .chain(std::iter::once(SchedulerEffect::ApplyGraphRevision {
263 revision_id: uuid::Uuid::new_v4().to_string(),
264 }))
265 .collect(),
266 RepairAction::InsertInterfaceNode { .. } | RepairAction::AddNode { .. } => {
267 vec![
268 SchedulerEffect::SpawnWork {
269 work_id: uuid::Uuid::new_v4().to_string(),
270 },
271 SchedulerEffect::ApplyGraphRevision {
272 revision_id: uuid::Uuid::new_v4().to_string(),
273 },
274 ]
275 }
276 RepairAction::RetireNode { .. } | RepairAction::ReplanSubgraph { .. } => {
277 vec![SchedulerEffect::ApplyGraphRevision {
278 revision_id: uuid::Uuid::new_v4().to_string(),
279 }]
280 }
281 RepairAction::StopNode { certificate_id, .. } => {
282 vec![SchedulerEffect::StopWithCertificate {
283 certificate_id: certificate_id.clone(),
284 }]
285 }
286 }
287}
288
289#[derive(Debug, Clone)]
291pub struct RunningTask {
292 pub node_id: String,
293 pub generation: u32,
294 pub footprint: Footprint,
295}
296
297#[derive(Debug)]
299pub struct Scheduler {
300 max_parallel: usize,
301 running: Vec<RunningTask>,
302 pub leases: LeaseTable,
303}
304
305impl Scheduler {
306 pub fn new(max_parallel: usize) -> Self {
307 Self {
308 max_parallel: max_parallel.max(1),
309 running: Vec::new(),
310 leases: LeaseTable::new(),
311 }
312 }
313
314 pub fn running_count(&self) -> usize {
315 self.running.len()
316 }
317
318 pub fn ready_nodes<'a, F>(
323 &self,
324 revision: &'a WorkGraphRevision,
325 footprint_of: F,
326 ) -> Vec<&'a WorkNode>
327 where
328 F: Fn(&WorkNode) -> Footprint,
329 {
330 let accepted: HashSet<&str> = revision
331 .nodes
332 .iter()
333 .filter(|n| n.is_accepted())
334 .map(|n| n.node_id.as_str())
335 .collect();
336
337 let mut ready = Vec::new();
338 let mut occupied: Vec<Footprint> =
341 self.running.iter().map(|t| t.footprint.clone()).collect();
342 let slots = self.max_parallel.saturating_sub(self.running.len());
343
344 for node in &revision.nodes {
345 if ready.len() >= slots {
346 break;
347 }
348 if !matches!(node.state, WorkNodeState::Pending | WorkNodeState::Ready) {
349 continue;
350 }
351 if !node.required_sensors.is_empty() {
353 continue;
354 }
355 let deps = revision.dependencies_of(&node.node_id);
357 if !deps.iter().all(|d| accepted.contains(d)) {
358 continue;
359 }
360 let fp = footprint_of(node);
362 if occupied.iter().any(|o| o.conflicts_with(&fp)) {
363 continue;
364 }
365 occupied.push(fp);
366 ready.push(node);
367 }
368 ready
369 }
370
371 pub fn start(&mut self, node: &WorkNode, footprint: Footprint) {
373 self.running.push(RunningTask {
374 node_id: node.node_id.clone(),
375 generation: node.generation,
376 footprint,
377 });
378 }
379
380 pub fn finish(&mut self, node_id: &str, generation: u32) {
382 self.running
383 .retain(|t| !(t.node_id == node_id && t.generation == generation));
384 }
385}
386
387#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
390#[serde(tag = "outcome", rename_all = "snake_case")]
391pub enum NodeOutcome {
392 Committed { node_id: String, generation: u32 },
393 Stopped { certificate: ResidualCertificate },
394 Escalated { node_id: String, reason: String },
395}
396
397pub fn recovery_is_total(revision: &WorkGraphRevision, outcomes: &[NodeOutcome]) -> bool {
400 let classified: HashMap<&str, usize> = {
401 let mut m: HashMap<&str, usize> = HashMap::new();
402 for o in outcomes {
403 let id = match o {
404 NodeOutcome::Committed { node_id, .. } => node_id.as_str(),
405 NodeOutcome::Stopped { certificate } => certificate.node_id.as_str(),
406 NodeOutcome::Escalated { node_id, .. } => node_id.as_str(),
407 };
408 *m.entry(id).or_default() += 1;
409 }
410 m
411 };
412 revision
413 .nodes
414 .iter()
415 .all(|n| classified.get(n.node_id.as_str()) == Some(&1))
416}
417
418#[cfg(test)]
419mod tests {
420 use super::*;
421 use crate::workgraph::{EdgeKind, GraphRevisionReason, NodeClass, WorkEdge};
422
423 fn node(id: &str) -> WorkNode {
424 WorkNode::new(id, format!("goal {id}"), NodeClass::Implement)
425 }
426
427 fn rev(nodes: Vec<WorkNode>, edges: Vec<WorkEdge>) -> WorkGraphRevision {
428 WorkGraphRevision::build(0, None, GraphRevisionReason::InitialPlan, nodes, edges).unwrap()
429 }
430
431 #[test]
432 fn disjoint_footprints_commute() {
433 let a = Footprint::new().write(Resource::File("a.rs".into()));
434 let b = Footprint::new().write(Resource::File("b.rs".into()));
435 assert!(a.commutes_with(&b));
436 }
437
438 #[test]
439 fn write_read_overlap_does_not_commute() {
440 let a = Footprint::new().write(Resource::File("shared.rs".into()));
441 let b = Footprint::new().read(Resource::File("shared.rs".into()));
442 assert!(a.conflicts_with(&b));
443 }
444
445 #[test]
446 fn manifest_writes_serialize() {
447 let a = Footprint::new().write(Resource::Manifest("Cargo.toml".into()));
448 let b = Footprint::new().write(Resource::Manifest("Cargo.toml".into()));
449 assert!(a.conflicts_with(&b));
450 }
451
452 #[test]
453 fn capability_table_is_a_conflict_resource() {
454 let grant = Footprint::new().write(Resource::Capability("write-src".into()));
456 let commit = Footprint::new().read(Resource::Capability("write-src".into()));
457 assert!(grant.conflicts_with(&commit));
458 }
459
460 #[test]
461 fn ledger_root_and_fresh_id_serialize() {
462 let a = Footprint::new().write(Resource::LedgerRoot);
463 let b = Footprint::new().write(Resource::LedgerRoot);
464 assert!(a.conflicts_with(&b));
465 let c = Footprint::new().write(Resource::FreshIdAllocator);
466 let d = Footprint::new().write(Resource::FreshIdAllocator);
467 assert!(c.conflicts_with(&d));
468 }
469
470 #[test]
471 fn independent_nodes_are_ready_in_parallel() {
472 let nodes = vec![node("a"), node("b")];
473 let revision = rev(nodes, vec![]);
474 let sched = Scheduler::new(4);
475 let fp = |n: &WorkNode| Footprint::new().write(Resource::File(format!("{}.rs", n.node_id)));
476 let ready = sched.ready_nodes(&revision, fp);
477 assert_eq!(ready.len(), 2);
478 }
479
480 #[test]
481 fn dependent_node_waits_for_its_predecessor() {
482 let nodes = vec![node("a"), node("b")];
483 let edges = vec![WorkEdge::new("a", "b", EdgeKind::RequiresArtifact)];
484 let revision = rev(nodes, edges);
485 let sched = Scheduler::new(4);
486 let fp = |n: &WorkNode| Footprint::new().write(Resource::File(format!("{}.rs", n.node_id)));
487 let ready = sched.ready_nodes(&revision, fp);
488 assert_eq!(ready.len(), 1);
490 assert_eq!(ready[0].node_id, "a");
491 }
492
493 #[test]
494 fn conflicting_footprints_do_not_dispatch_together() {
495 let nodes = vec![node("a"), node("b")];
496 let revision = rev(nodes, vec![]);
497 let sched = Scheduler::new(4);
498 let fp = |_n: &WorkNode| Footprint::new().write(Resource::Manifest("Cargo.toml".into()));
500 let ready = sched.ready_nodes(&revision, fp);
501 assert_eq!(ready.len(), 1);
502 }
503
504 #[test]
505 fn inserted_node_becomes_ready_after_revision() {
506 let mut nodes = vec![node("a")];
508 nodes[0].state = WorkNodeState::Stable;
509 let revision = rev(nodes, vec![]);
510 let mut nodes2 = revision.nodes.clone();
512 nodes2.push(node("b"));
513 let revision2 = WorkGraphRevision::build(
514 1,
515 Some(revision.revision_id.clone()),
516 GraphRevisionReason::LocalRepair,
517 nodes2,
518 vec![],
519 )
520 .unwrap();
521 let sched = Scheduler::new(4);
522 let fp = |n: &WorkNode| Footprint::new().write(Resource::File(format!("{}.rs", n.node_id)));
523 let ready = sched.ready_nodes(&revision2, fp);
524 assert!(
525 ready.iter().any(|n| n.node_id == "b"),
526 "inserted node must be ready"
527 );
528 }
529
530 #[test]
531 fn leases_are_exclusive() {
532 let mut table = LeaseTable::new();
533 let scope = Resource::Toolchain("cargo".into());
534 let l1 = table.acquire("w1", LeaseKind::Toolchain, scope.clone());
535 assert!(l1.is_some());
536 assert!(table
537 .acquire("w2", LeaseKind::Toolchain, scope.clone())
538 .is_none());
539 table.release(&l1.unwrap());
540 assert!(table.acquire("w2", LeaseKind::Toolchain, scope).is_some());
541 }
542
543 #[test]
544 fn repair_retry_becomes_requeue_effect() {
545 let effects = repair_to_effects(&RepairAction::RetryNode {
546 node_id: "a".into(),
547 generation: 0,
548 });
549 assert_eq!(
550 effects,
551 vec![SchedulerEffect::RequeueNode {
552 node_id: "a".into(),
553 generation: 0,
554 reason: "retry".into()
555 }]
556 );
557 }
558
559 #[test]
560 fn split_produces_spawn_and_revision_effects() {
561 let effects = repair_to_effects(&RepairAction::SplitNode {
562 node_id: "a".into(),
563 generation: 0,
564 child_goals: vec!["x".into(), "y".into()],
565 });
566 let spawns = effects
567 .iter()
568 .filter(|e| matches!(e, SchedulerEffect::SpawnWork { .. }))
569 .count();
570 let revs = effects
571 .iter()
572 .filter(|e| matches!(e, SchedulerEffect::ApplyGraphRevision { .. }))
573 .count();
574 assert_eq!(spawns, 2);
575 assert_eq!(revs, 1);
576 }
577
578 #[test]
579 fn recovery_totality_requires_every_node_classified() {
580 let nodes = vec![node("a"), node("b")];
581 let revision = rev(nodes, vec![]);
582 let outcomes = vec![NodeOutcome::Committed {
583 node_id: "a".into(),
584 generation: 0,
585 }];
586 assert!(!recovery_is_total(&revision, &outcomes));
587 let outcomes = vec![
588 NodeOutcome::Committed {
589 node_id: "a".into(),
590 generation: 0,
591 },
592 NodeOutcome::Escalated {
593 node_id: "b".into(),
594 reason: "blocked".into(),
595 },
596 ];
597 assert!(recovery_is_total(&revision, &outcomes));
598 }
599}