conductor

CI task system
git clone git://git.finwo.net/app/conductor
Log | Files | Refs | README | LICENSE

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 }