Skip to content
academia.sh

Lesson 08 / 21

Triggers

Event, schedule, and manual triggering are all run on the same two-week fictional calendar: run count, wasted runs made when nothing has changed or when a change does not affect the pipeline's output, the delay between a change and its run, and overlapping runs are counted, and then a path-based filter is added and the drop in wasted runs is measured together with the real change it misses.

Published Updated

In this lesson

  • Compares three trigger types on the same calendar by run count, wasted runs, delay, and overlap
  • Measures the schedule trigger's marginal contribution when the event trigger is already present, and shows the gate that can be removed
  • Counts the two faces of the path-based filter together: the wasted runs it blocks and the real change it misses
  • Ties the filter list's gap to the same class of error as the cache key's gap
Contents

The pipeline is defined, validated, and versioned. The previous two lessons used run counts between 10 and 80 a day and calculated thresholds from those numbers, but never said where the number came from. What starts a run is a trigger, and a trigger is a gate too — what it holds is the run itself. A pipeline that never runs catches no defect; one that runs needlessly only spends money and queue.

There are three types: bound to an event (when a change lands in the repository), bound to a schedule (at set intervals), and pulled manually (when someone presses a button). The three produce very different numbers on the same calendar. This lesson counts four of them: run count, wasted runs, delayed feedback, and overlapping runs — then adds a path-based filter and reads the same numbers again.

PD43. The calendar is two weeks of the fictional measurement network, produced with node; the generator is self-written, seed 9173. There are ten workdays and no weekend changes. PD44. Path groups and their proportions are declared in the model; docs/ does not affect the pipeline’s output, the others do. PD45. Run duration is 203 seconds — the critical path measured in earlier lessons; an overlapping run starts before the previous one ends. PD46. Delay is the time between a change and the start of the first run covering it; a run’s own duration is the same for every trigger, so it stays out of the delay. PD47. Manual triggering is modeled at 17:30 on every workday and skipped with a probability of one in four. PD48. A filtered-out run does not advance; uncovered changes carry over to the next run.

Three Triggers, the Same Calendar

// calendar.mjs — two-week fictional change calendar and triggers (model)
export const SEED = 9173;
export const generator = (t) => () => ((t = (t * 48271) % 2147483647) / 2147483647);
export const DAY = 86400, RUN = 203; // critical path duration measured in 05 (seconds)

// Path groups: an effective one changes the pipeline's output, an ineffective one does not.
export const PATHS = [["src/", 45, 1], ["test/", 20, 1], ["docs/", 15, 0],
  ["config/", 10, 1], ["shared-lib/", 10, 1]];
export const FILTER = ["src/", "test/", "config/"]; // shared-lib/ was not added to the list

export const calendar = () => {
  const rnd = generator(SEED), changes = [];
  for (let day = 0; day < 14; day++) {
    if (day % 7 >= 5) continue; // weekend
    for (let i = 3 + Math.floor(rnd() * 6); i > 0; i--) {
      const p = rnd() * 100;
      let t = 0, path = PATHS[PATHS.length - 1];
      for (const entry of PATHS) { t += entry[1]; if (p < t) { path = entry; break; } }
      changes.push({ at: day * DAY + Math.round((9 + rnd() * 9) * 3600), path: path[0], effective: path[2] });
    }
  }
  return changes.sort((a, b) => a.at - b.at);
};

export const TRIGGER = {
  event: (changes) => changes.map((x) => x.at),
  "time-4h": () => Array.from({ length: 84 }, (_, i) => Math.floor(i / 6) * DAY + (i % 6) * 4 * 3600),
  "time-nightly": () => Array.from({ length: 14 }, (_, day) => day * DAY + 2 * 3600),
  manual: () => { const rnd = generator(SEED + 1);
    return [...Array(14).keys()].filter((day) => day % 7 < 5 && rnd() > 0.25).map((day) => day * DAY + 17.5 * 3600); },
};

// Empty run: nothing has changed since the previous run. Irrelevant run: none of the changes it
// covers affect the pipeline's output. Overlapping: a run that starts before the previous one ends.
export const measure = (runs, changes) => {
  let empty = 0, irrelevant = 0, overlapping = 0, prev = -Infinity;
  runs.forEach((run, i) => {
    const covered = changes.filter((c) => c.at > prev && c.at <= run);
    if (!covered.length) empty++;
    else if (!covered.some((c) => c.effective)) irrelevant++;
    if (i > 0 && run - runs[i - 1] < RUN) overlapping++;
    prev = run;
  });
  const delay = [];
  let uncovered = 0;
  for (const c of changes.filter((x) => x.effective)) {
    const run = runs.find((t) => t >= c.at);
    if (run === undefined) uncovered++; else delay.push(run - c.at);
  }
  return { runs: runs.length, empty, irrelevant, overlapping, uncovered,
    avg: delay.reduce((a, b) => a + b, 0) / (delay.length || 1), longest: Math.max(0, ...delay) };
};

// Path-based filter: a run happens only if one of the changes it covers matches the list.
// A filtered-out run does not advance "prev"; uncovered changes carry over to the next run.
export const filter = (runs, changes, list = FILTER) => {
  const kept = [];
  let prev = -Infinity;
  for (const run of runs)
    if (changes.some((c) => c.at > prev && c.at <= run && list.includes(c.path))) { kept.push(run); prev = run; }
  return kept;
};
// trigger.mjs — three trigger types on the same two-week calendar
import { calendar, TRIGGER, measure, PATHS, SEED } from "./calendar.mjs";

const changes = calendar();
const hr = (s) => (s / 3600).toFixed(1);
const distribution = PATHS.map(([prefix]) => `${prefix}${changes.filter((x) => x.path === prefix).length}`).join("  ");
console.log(`seed ${SEED}; ${changes.length} changes in two weeks, ${changes.filter((x) => x.effective).length} effective`);
console.log(`path distribution: ${distribution}`);

console.log("\ntrigger          runs  empty  irrelevant  wasted  overlap  avg delay  longest  uncovered");
const results = {};
for (const [name, make] of Object.entries(TRIGGER)) {
  const r = (results[name] = measure(make(changes), changes));
  console.log(name.padEnd(16) + String(r.runs).padStart(6) + String(r.empty).padStart(7) +
    String(r.irrelevant).padStart(12) + String(r.empty + r.irrelevant).padStart(10) + String(r.overlapping).padStart(9) +
    (hr(r.avg) + " hr").padStart(13) + (hr(r.longest) + " hr").padStart(9) + String(r.uncovered).padStart(13));
}

// The time trigger's added contribution when the event trigger is already there.
const eventRuns = TRIGGER.event(changes);
for (const name of ["time-4h", "time-nightly"]) {
  const combined = [...eventRuns, ...TRIGGER[name]()].sort((a, b) => a - b);
  const r = measure(combined, changes);
  console.log(`\nevent + ${name}: ${r.runs} runs (event alone ${results.event.runs}), ` +
    `empty ${r.empty}, avg delay ${hr(r.avg)} hr — the event trigger's own delay ${hr(results.event.avg)} hr`);
  console.log(`  of the ${r.runs - results.event.runs} runs added, ${r.empty} are empty; ` +
    `contribution to delay ${(results.event.avg - r.avg).toFixed(1)} seconds`);
}
seed 9173; 53 changes in two weeks, 46 effective
path distribution: src/21  test/12  docs/7  config/3  shared-lib/10

trigger          runs  empty  irrelevant  wasted  overlap  avg delay  longest  uncovered
event               53      0           7         7        2       0.0 hr   0.0 hr            0
time-4h             84     58           1        59        0       2.0 hr   3.9 hr            0
time-nightly        14      4           0         4        0      12.4 hr  16.8 hr            0
manual               6      0           0         0        0      22.2 hr 103.1 hr            0

event + time-4h: 137 runs (event alone 53), empty 84, avg delay 0.0 hr — the event trigger's own delay 0.0 hr
  of the 84 runs added, 84 are empty; contribution to delay 0.0 seconds

event + time-nightly: 67 runs (event alone 53), empty 14, avg delay 0.0 hr — the event trigger's own delay 0.0 hr
  of the 14 runs added, 14 are empty; contribution to delay 0.0 seconds

Reading the Four Numbers

The event trigger makes 53 runs and its delay is zero: every change gives birth to its own run. Its wasted runs are 7, all of one kind — changes that only touch docs/ cost a full run without changing the pipeline’s output. Its overlapping runs are 2: two changes arrived less than 203 seconds apart and the second run started before the first finished. Overlap does not only spend a slot; when the first run finishes, its result already belongs to a stale tree.

The four-hour schedule trigger is bad on both axes at once. It makes 84 runs — 58 percent more than the event trigger — and 58 of them are completely empty, meaning no change has come in since the previous run. Its wasted-run rate is 59/84, that is, 70 percent. Against that, its average delay is not zero but 2 hours, with the longest wait at 3.9 hours. It runs more, and it reports later.

Manual triggering is the most deceptive row in the table. Its wasted runs are zero and its run count is only 6; on the cost column it wins by a wide margin. The delay column makes the call: an average of 22.2 hours, a longest of 103.1 hours — more than four days. Zero wasted runs is not a virtue either: runs are so sparse that each one covers dozens of changes at once, so none of them go to waste. That same sparseness leaves it unclear which change broke it when a run turns red.

The Gate That Can Be Removed

Triggers can also be used together, and in practice the most common combination is event plus schedule. The measurement asks what this combination adds. When the four-hour schedule trigger is added alongside the event trigger, run count rises from 53 to 137; all 84 of the 84 added runs are empty, because every change has already triggered its own run. Its contribution to delay is 0.0 seconds.

By this course’s measure, the decision is clear: a gate is worth only what it alone catches, and this gate has nothing that belongs only to itself. The four-hour schedule trigger, with the event trigger already present, is the gate that should be removed — 84 empty runs, zero gain.

The nightly run adds 14 runs by the same measure and also shows zero contribution on this calendar. Whether it should be removed cannot be measured here, and that is written down: this calendar only models changes that arrive in the repository. The nightly run’s defense is a change outside it — a dependency’s content growing without its list file changing. Earlier lessons measured that the cache key misses exactly this kind of change. The nightly run is the antidote to that blindness, but it is invisible here; deciding it needs a different calendar.

The Path Filter

The event trigger’s only flaw was wasted runs: 7 runs were spent on docs/ changes. The known remedy is a path filter, which ties a run to the change of only certain paths. The filter list carries three paths; the shared library directory, added to the repository later, was not written into the list.

// filter.mjs — path-based filter: the drop in wasted runs and the real change it misses
import { calendar, TRIGGER, measure, filter, FILTER } from "./calendar.mjs";

const changes = calendar(), hr = (s) => (s / 3600).toFixed(1);
const arrow = (a, b) => `${a} -> ${b}`.padStart(12);
console.log(`filter list: ${FILTER.join(" ")}  (shared-lib/ is not on the list)`);
console.log("\ntrigger            runs      wasted  uncovered    avg delay");
for (const [name, make] of Object.entries(TRIGGER)) {
  const runs = make(changes), before = measure(runs, changes), after = measure(filter(runs, changes), changes);
  console.log(name.padEnd(16) + arrow(before.runs, after.runs) + arrow(before.empty + before.irrelevant, after.empty + after.irrelevant) +
    arrow(before.uncovered, after.uncovered) + `  ${hr(before.avg)} -> ${hr(after.avg)} hr`);
}

// Changes on a path not in the filter list: cannot trigger their own run, ride along on another one.
const filtered = filter(TRIGGER.event(changes), changes);
const outside = changes.filter((x) => x.path === "shared-lib/");
const wait = outside.map((x) => { const run = filtered.find((t) => t >= x.at); return run === undefined ? null : run - x.at; });
const riding = wait.filter((w) => w !== null);
console.log(`\nshared-lib/ changes: ${outside.length}, none of them triggered its own run`);
console.log(`  ${riding.length} of them rode along on another change's run, averaging ` +
  `${hr(riding.reduce((a, b) => a + b, 0) / riding.length)} hr of delay, the longest being ${hr(Math.max(...riding))} hr`);
console.log(`  ${wait.length - riding.length} of them never ran at all across the two weeks`);

// The gate's account: the wasted runs it holds, the real changes it falsely holds, the delay it adds.
const eventRaw = measure(TRIGGER.event(changes), changes), eventFiltered = measure(filtered, changes);
const corrected = measure(filter(TRIGGER.event(changes), changes, [...FILTER, "shared-lib/"]), changes);
console.log(`\ngate account (event trigger + path filter)`);
console.log(`  holds        : ${eventRaw.empty + eventRaw.irrelevant} wasted runs (docs/ changes)`);
console.log(`  falsely holds: ${outside.length} real changes, ${eventFiltered.uncovered} of them never ran at all`);
console.log(`  adds         : average delay ${hr(eventRaw.avg)} -> ${hr(eventFiltered.avg)} hr`);
console.log(`  once the list is fixed: ${corrected.runs} runs, ${corrected.empty + corrected.irrelevant} wasted, ` +
  `${corrected.uncovered} uncovered, delay ${hr(corrected.avg)} hr`);
filter list: src/ test/ config/  (shared-lib/ is not on the list)

trigger            runs      wasted  uncovered    avg delay
event               53 -> 36      7 -> 0      0 -> 1  0.0 -> 0.6 hr
time-4h             84 -> 23     59 -> 0      0 -> 0  2.0 -> 2.6 hr
time-nightly        14 -> 10      4 -> 0      0 -> 0  12.4 -> 12.4 hr
manual                6 -> 6      0 -> 0      0 -> 0  22.2 -> 22.2 hr

shared-lib/ changes: 10, none of them triggered its own run
  9 of them rode along on another change's run, averaging 3.1 hr of delay, the longest being 19.9 hr
  1 of them never ran at all across the two weeks

gate account (event trigger + path filter)
  holds        : 7 wasted runs (docs/ changes)
  falsely holds: 10 real changes, 1 of them never ran at all
  adds         : average delay 0.0 -> 0.6 hr
  once the list is fixed: 46 runs, 0 wasted, 0 uncovered, delay 0.0 hr

The Filter’s Two Faces

If the first row is read alone, the filter is a success: on the event trigger, runs drop from 53 to 36, and wasted runs from 7 to zero. Once the second number is looked at, the arithmetic falls apart. The runs that dropped number 17, yet only 7 were wasted. The 10 runs in between were runs of changes that really did affect the pipeline’s output.

All ten changes are in the shared library directory and none triggered its own run. Nine entered the pipeline by riding along on a later change’s run, with an average delay of 3.1 hours and a longest of 19.9 hours. One was never run at all across the two weeks. Average delay rose from 0.0 to 0.6 hours — small-looking, because 36 of the 46 effective changes were never delayed; the average hides what happened to the ten.

The gate account is complete in three lines: the filter holds 7 wasted runs, falsely holds 10 real changes, and one of them never runs at all. What it falsely holds outnumbers what it correctly holds. As it stands, the gate is harmful, and removing it would be better.

Removing it is still not the right fix: the problem is not in the gate but in its list. Once the shared library path is added, the measurement gives the last row: 46 runs, 0 wasted, 0 uncovered, 0.0 hours of delay — seven fewer runs than the raw event trigger, with no loss. That is the filter’s value set up correctly; its cost set up wrong is the three lines above.

The filter’s effect on the schedule trigger is even larger: 84 runs drop to 23, 59 wasted runs drop to zero, and no uncovered change is born, because the changes a filtered-out run would have covered carry over to the next one. On manual triggering, nothing changes at all: all six of the six runs already cover a change that matches the filter.

This measurement’s real finding is hiding in the list. The pipeline’s inputs are declared in two separate places across this course: in the cache key, and in the trigger filter. Both are hand-written, both go stale as the repository grows, and both produce the same result when they are incomplete — a change exists, but the gate does not run. In the earlier measurement, this let two defects escape; here it delayed ten changes and never ran one at all.

Summary

  • On the same two-week calendar: the event trigger gave 53 runs and zero delay, the four-hour schedule trigger 84 runs and 2 hours, the nightly run 14 runs and 12.4 hours, manual triggering 6 runs and 22.2 hours (longest 103.1).
  • The four-hour schedule trigger is bad on both axes: it runs 58 percent more than the event trigger (59/84 wasted) and reports later. Manual triggering’s zero wasted runs come from sparseness, not virtue, and that sparseness hides which change broke it.
  • With the event trigger present, the four-hour schedule trigger is the gate that should be removed: all 84 runs it adds are empty and its contribution to delay is 0.0 seconds. The nightly run’s defense is a change outside the repository, which this calendar does not measure.
  • The path filter brought the event trigger’s runs down from 53 to 36, but of the 17 dropped runs only 7 were wasted. Nine of the 10 changes in the unlisted directory rode along on another run with an average delay of 3.1 hours; one never ran across the two weeks.
  • Gate account: 7 wasted runs held, 10 real changes falsely held. With the list fixed the numbers reach 46 runs, 0 wasted, 0 uncovered, 0.0 hours of delay — the defect is not in the gate but in the list. The pipeline’s inputs are declared by hand in two places (the cache key and the trigger filter), and an incomplete list gives the same result in both: the change exists, the gate does not run.

Next Step

The pipeline is complete at the end of this topic: its gates were counted, what each gate alone catches was measured, its duration was brought down to the 203-second critical path, and where and when it runs was decided. All of it rested on one silent assumption: that a change coming out of a green run had reached production. But a green run only says the change is ready in the pipeline, not that it is in production. How it enters production is a separate decision — all at once, to a slice first, by switching between two environments, or from behind a flag in the code? Each brings its own rollback path and observation criterion. The pipeline’s last gate stands there, and what it holds, what it misses, and how long it holds are measured with the same three numbers. The next topic takes up that decision.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close