conductor

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

dag.test.js (6191B)


      1 // test/dag.test.js - dependency graph traversal
      2 //
      3 // These cover the two scheduling defects carried over from the prototype:
      4 // serialized dispatch, and a failure cascade that only skipped direct
      5 // dependents.
      6 
      7 import test from 'node:test';
      8 import assert from 'node:assert/strict';
      9 import { topologicalOrder, depths, transitiveDependents, runnable, CycleError } from '../src/lib/pipeline/dag.js';
     10 
     11 // a -> b -> d, a -> c -> d, plus an unrelated island.
     12 const GRAPH = [
     13   { name: 'a', needs: [] },
     14   { name: 'b', needs: ['a'] },
     15   { name: 'c', needs: ['a'] },
     16   { name: 'd', needs: ['b', 'c'] },
     17   { name: 'island', needs: [] },
     18 ];
     19 
     20 function states(overrides = {}) {
     21   const map = new Map(GRAPH.map((j) => [j.name, 'queued']));
     22   for (const [k, v] of Object.entries(overrides)) map.set(k, v);
     23   return map;
     24 }
     25 
     26 test('topological order places dependencies first', () => {
     27   const order = topologicalOrder(GRAPH).map((j) => j.name);
     28   assert.ok(order.indexOf('a') < order.indexOf('b'));
     29   assert.ok(order.indexOf('a') < order.indexOf('c'));
     30   assert.ok(order.indexOf('b') < order.indexOf('d'));
     31   assert.ok(order.indexOf('c') < order.indexOf('d'));
     32 });
     33 
     34 test('topological order is deterministic', () => {
     35   const first = topologicalOrder(GRAPH).map((j) => j.name);
     36   const shuffled = [...GRAPH].reverse();
     37   assert.deepEqual(topologicalOrder(shuffled).map((j) => j.name), first);
     38 });
     39 
     40 test('a cycle is detected and named', () => {
     41   const cyclic = [
     42     { name: 'x', needs: ['z'] },
     43     { name: 'y', needs: ['x'] },
     44     { name: 'z', needs: ['y'] },
     45   ];
     46   assert.throws(() => topologicalOrder(cyclic), CycleError);
     47   try {
     48     topologicalOrder(cyclic);
     49   } catch (e) {
     50     // The reported path returns to where it started.
     51     assert.equal(e.cycle[0], e.cycle[e.cycle.length - 1]);
     52   }
     53 });
     54 
     55 test('depth reflects the longest path, not insertion order', () => {
     56   const d = depths(GRAPH);
     57   assert.equal(d.get('a'), 0);
     58   assert.equal(d.get('island'), 0);
     59   assert.equal(d.get('b'), 1);
     60   assert.equal(d.get('d'), 2);
     61 });
     62 
     63 test('independent tasks are runnable at the same time', () => {
     64   // The prototype could only ever return one task here.
     65   const ready = runnable(GRAPH, states()).map((j) => j.name);
     66   assert.deepEqual(ready.sort(), ['a', 'island']);
     67 });
     68 
     69 test('a task becomes runnable only once every dependency succeeds', () => {
     70   assert.deepEqual(
     71     runnable(GRAPH, states({ a: 'success', b: 'success' })).map((j) => j.name).sort(),
     72     ['c', 'island']
     73   );
     74   assert.deepEqual(
     75     runnable(GRAPH, states({ a: 'success', b: 'success', c: 'success', island: 'success' })).map((j) => j.name),
     76     ['d']
     77   );
     78 });
     79 
     80 test('a running dependency does not release its dependents', () => {
     81   assert.equal(runnable(GRAPH, states({ a: 'running' })).some((j) => j.name === 'b'), false);
     82 });
     83 
     84 test('failure skips transitive dependents, not just direct ones', () => {
     85   // This is the prototype bug: it would have skipped only b and c, leaving
     86   // d queued and dispatchable with its inputs missing.
     87   const skipped = transitiveDependents(GRAPH, ['a']);
     88   assert.deepEqual([...skipped].sort(), ['b', 'c', 'd']);
     89   assert.equal(skipped.has('island'), false);
     90 });
     91 
     92 test('a failed dependency never releases a dependent', () => {
     93   assert.equal(runnable(GRAPH, states({ a: 'failed' })).some((j) => j.name === 'b'), false);
     94 });
     95 
     96 test('a skipped dependency releases a dependent, since a skip is not a failure', () => {
     97   assert.equal(runnable(GRAPH, states({ a: 'skipped' })).some((j) => j.name === 'b'), true);
     98 });
     99 
    100 test('allowed failures can satisfy a dependency when asked to', () => {
    101   const ready = runnable(GRAPH, states({ a: 'failed' }), { satisfied: ['success', 'failed'] });
    102   assert.deepEqual(ready.map((j) => j.name).sort(), ['b', 'c', 'island']);
    103 });
    104 
    105 test('transitive dependents of a leaf is empty', () => {
    106   assert.equal(transitiveDependents(GRAPH, ['d']).size, 0);
    107 });
    108 
    109 test('graph helpers tolerate dependencies outside the set', () => {
    110   const partial = [{ name: 'only', needs: ['absent'] }];
    111   assert.deepEqual(topologicalOrder(partial).map((j) => j.name), ['only']);
    112   assert.equal(depths(partial).get('only'), 0);
    113 });
    114 
    115 // --- after ---
    116 
    117 // notify waits for report to finish, but not for it to have worked.
    118 const AFTER_GRAPH = [
    119   { name: 'build', needs: [], after: [] },
    120   { name: 'report', needs: ['build'], after: [] },
    121   { name: 'notify', needs: [], after: ['report'] },
    122 ];
    123 
    124 function afterStates(overrides = {}) {
    125   const map = new Map(AFTER_GRAPH.map((j) => [j.name, 'queued']));
    126   for (const [k, v] of Object.entries(overrides)) map.set(k, v);
    127   return map;
    128 }
    129 
    130 test('after orders a task without putting it downstream of a failure', () => {
    131   const order = topologicalOrder(AFTER_GRAPH).map((j) => j.name);
    132   assert.ok(order.indexOf('report') < order.indexOf('notify'));
    133 
    134   // The whole point: a build failure carries report off with it, and leaves
    135   // notify to run.
    136   const skipped = transitiveDependents(AFTER_GRAPH, ['build']);
    137   assert.deepEqual([...skipped], ['report']);
    138 });
    139 
    140 test('a cycle through after is still a cycle', () => {
    141   // after waits just as needs does, so a loop of them deadlocks the same way.
    142   const cyclic = [
    143     { name: 'x', needs: [], after: ['y'] },
    144     { name: 'y', needs: ['x'], after: [] },
    145   ];
    146   assert.throws(() => topologicalOrder(cyclic), CycleError);
    147 });
    148 
    149 test('an after dependency releases its dependent whatever it finished as', () => {
    150   for (const state of ['success', 'failed', 'skipped', 'cancelled']) {
    151     const ready = runnable(AFTER_GRAPH, afterStates({ build: 'success', report: state }));
    152     assert.ok(ready.some((j) => j.name === 'notify'), `should run after a ${state} dependency`);
    153   }
    154 });
    155 
    156 test('an unfinished after dependency still holds its dependent back', () => {
    157   for (const state of ['queued', 'running']) {
    158     const ready = runnable(AFTER_GRAPH, afterStates({ report: state }));
    159     assert.equal(ready.some((j) => j.name === 'notify'), false, `should wait on a ${state} dependency`);
    160   }
    161 });
    162 
    163 test('depth counts after edges, since the task still runs later', () => {
    164   const d = depths(AFTER_GRAPH);
    165   assert.equal(d.get('build'), 0);
    166   assert.equal(d.get('report'), 1);
    167   assert.equal(d.get('notify'), 2);
    168 });