dag.js (4337B)
1 // src/lib/pipeline/dag.js - dependency graph checks and traversal 2 // 3 // The prototype stored a single integer level per task and treated the lowest 4 // queued level as the next runnable task. That serialized every run and, on 5 // failure, only skipped direct dependents, so a transitive dependent could 6 // still be dispatched with its dependency missing. Both problems come from 7 // throwing the graph away, so the graph is kept here and used directly. 8 9 // An edge is one of two kinds. `needs` waits for the dependency to succeed, 10 // `after` only waits for it to finish. Ordering and cycle detection care 11 // about both, since either kind makes one task wait for another. Failure 12 // propagation cares about `needs` alone, which is the whole point of `after`. 13 export const ALL_EDGES = ['needs', 'after']; 14 15 // Terminal states, which is what an `after` edge waits for. 16 const FINISHED = ['success', 'failed', 'skipped', 'cancelled']; 17 18 // What lets a `needs` dependent through. A skip is not a failure. 19 const SATISFIED = ['success', 'skipped']; 20 21 function depsOf(task, edges) { 22 if (edges.length === 1) return task[edges[0]] ?? []; 23 return edges.flatMap((kind) => task[kind] ?? []); 24 } 25 26 export class CycleError extends Error { 27 constructor(cycle) { 28 super(`pipeline contains a dependency cycle: ${cycle.join(' -> ')}`); 29 this.name = 'CycleError'; 30 this.cycle = cycle; 31 } 32 } 33 34 // Returns the tasks in an order where every dependency precedes its 35 // dependents. Throws CycleError naming the cycle when none exists. 36 export function topologicalOrder(tasks, { edges = ALL_EDGES } = {}) { 37 const byName = new Map(tasks.map((j) => [j.name, j])); 38 const state = new Map(); 39 const order = []; 40 const stack = []; 41 42 function visit(name) { 43 const current = state.get(name); 44 if (current === 'done') return; 45 if (current === 'active') { 46 const from = stack.indexOf(name); 47 throw new CycleError([...stack.slice(from), name]); 48 } 49 50 state.set(name, 'active'); 51 stack.push(name); 52 for (const dep of depsOf(byName.get(name), edges)) { 53 if (byName.has(dep)) visit(dep); 54 } 55 stack.pop(); 56 state.set(name, 'done'); 57 order.push(name); 58 } 59 60 // Sorted so that an unchanged pipeline always produces the same order. 61 for (const name of [...byName.keys()].sort()) visit(name); 62 return order.map((name) => byName.get(name)); 63 } 64 65 // Depth of each task, where a task with no dependencies is 0. Used only for 66 // display; scheduling reads the edges, never the depth. 67 export function depths(tasks, { edges = ALL_EDGES } = {}) { 68 const byName = new Map(tasks.map((j) => [j.name, j])); 69 const out = new Map(); 70 for (const task of topologicalOrder(tasks, { edges })) { 71 const deps = depsOf(task, edges).filter((d) => byName.has(d)); 72 out.set(task.name, deps.length === 0 ? 0 : Math.max(...deps.map((d) => out.get(d))) + 1); 73 } 74 return out; 75 } 76 77 // Every task reachable by following dependents from the given names. This is 78 // what a failure must skip: direct dependents alone leave transitive ones 79 // runnable against a dependency that never produced anything. 80 // 81 // Needs edges only, by default. A task declaring `after` asked to run 82 // whatever became of its predecessor, so a failure must not carry it off. 83 export function transitiveDependents(tasks, startNames, { edges = ['needs'] } = {}) { 84 const dependents = new Map(tasks.map((j) => [j.name, []])); 85 for (const task of tasks) { 86 for (const dep of depsOf(task, edges)) { 87 if (dependents.has(dep)) dependents.get(dep).push(task.name); 88 } 89 } 90 91 const seen = new Set(); 92 const queue = [...startNames]; 93 while (queue.length > 0) { 94 const name = queue.shift(); 95 for (const child of dependents.get(name) ?? []) { 96 if (seen.has(child)) continue; 97 seen.add(child); 98 queue.push(child); 99 } 100 } 101 return seen; 102 } 103 104 // Tasks whose dependencies have all reached a satisfying state. Passed the 105 // current state of every task by name. 106 export function runnable(tasks, stateByName, { satisfied = SATISFIED, finished = FINISHED } = {}) { 107 return tasks.filter((task) => { 108 if (stateByName.get(task.name) !== 'queued') return false; 109 if (!(task.needs ?? []).every((dep) => satisfied.includes(stateByName.get(dep)))) return false; 110 return (task.after ?? []).every((dep) => finished.includes(stateByName.get(dep))); 111 }); 112 }