Skip to content
academia.sh

Lesson 01 / 21

Continuous Integration

Scanning the merge interval: how conflicting files, resolution rounds, and errors born in resolution grow as the interval grows, how many runs and how many runner minutes frequency's own cost takes, and what the merge gate alone catches and how many clean changes it stops for nothing.

Published Updated

In this lesson

  • Scans the merge interval to measure conflicting files, resolution rounds, and errors born in resolution
  • Counts frequent merging's own cost as run count and runner minutes
  • Writes out separately what the merge gate catches, misses, falsely holds, and the time it adds
Contents

The previous course left a cluster behind: the desired state is written down somewhere, a reconciler keeps applying it, and the actual state is pulled back whenever it drifts. The question left standing at its close was — who changes that desired state, on what evidence, and through which check? This course builds that path. Every step that stands on the route from a developer’s machine to production, and either lets a change through or does not, is a gate, and the gate is this course’s unit of measure.

The first gate on the path is the oldest one: a change entering the main branch. Continuous integration is every developer returning their work to the main branch at frequent intervals and running an automated check on every return. “Frequent” is not a number but an axis, and this lesson scans that axis. What gets scanned is the merge interval: how long a branch lives apart from the main branch. As the interval grows, two things grow together — the number of conflicting files and the errors born while resolving the conflict. As the interval shrinks, something else grows — the number of runs and runner time. This is where frequency stops being free. The feedback delay measured in the Process Models topic is not remeasured here; what this lesson measures is the conflict itself.

The Repository Model

The measurement runs on the repository of the regional measurement network: the service that collects water-meter readings, the service that verifies the readings, and the billing and work-order services. This network is fictional; the repository below is a model too, and no real repository was scanned. Durations are model minutes and are independent of the machine the measurement runs on.

PD1 — the repository is 120 files split across four services; eight developers work twenty workdays, and a day is 480 model minutes. Each service’s first three files are interface files: they carry another service’s expectations.

PD2 — a developer touches 0.5 files per hour. Total work is independent of the interval: every scan row has 640 file touches, and the only thing that changes is how many merges this work is split into.

PD3 — one fifth of the files are hot and are touched eight times as often; a developer prefers their own service 75 percent of the time. The generator is self-written and the seed is visible.

PD4 — resolution per conflicting file is 20 model minutes. The main branch keeps advancing while resolution runs; if a new merge touches the same files during that time, a new round opens, up to five rounds.

PD5 — the probability that resolving a conflicting file gives birth to an error is 0.09. Finding and fixing, later on, either an error born in resolution or an interface mismatch the gate missed costs 180 model minutes.

PD6 — one pipeline run spends 14 model minutes of runner time; two percent of runs flake red and stop a clean change for nothing, demanding one more run.

// repo.mjs — model of the fictional regional measurement network's repository: service, file, developer
// Seeded generator: 32-bit linear congruential map; no external library is used.
export const generator = (seed) => () => {
  seed = (seed * 1664525 + 1013904223) >>> 0;
  return seed / 4294967296;
};

// PD1: four services, files per service; the first three files are interface files.
export const services = [
  { name: 'collector', files: 38 },
  { name: 'verifier', files: 26 },
  { name: 'billing', files: 31 },
  { name: 'work-order', files: 25 },
];

export const files = services.flatMap((s, si) =>
  Array.from({ length: s.files }, (_, i) => ({
    name: `${s.name}/${i}`, service: si, interface: i < 3,
    // PD3: one fifth of the files are hot and touched eight times as often.
    weight: i % 5 === 0 ? 8 : 1,
  })));

// Weighted file pick: a developer prefers their own service 75 percent of the time.
export const pickFile = (r, developer) => {
  const own = r() < 0.75;
  const pool = own ? files.filter((f) => f.service === developer % 4) : files;
  const total = pool.reduce((a, f) => a + f.weight, 0);
  let x = r() * total;
  for (const f of pool) { x -= f.weight; if (x <= 0) return f; }
  return pool[pool.length - 1];
};

export const DEVELOPERS = 8;
export const DAY = 20, DAY_MIN = 480;          // PD1: twenty workdays, 480 model minutes per day
export const RATE = 0.5;                       // PD2: 0.5 files touched per hour
export const RESOLVE_MIN = 20;                 // PD4: resolution time per conflicting file
export const ERROR_PROBABILITY = 0.09;         // PD5: probability an error is born during resolution
export const LATER_MIN = 180;                  // PD5: cost of a later-found error
export const FLAKY = 0.02;                     // PD6: two percent of runs falsely hold
export const RUN_MIN = 14;                     // PD6: runner time of one pipeline run

Scanning the Interval

The scan runs the same work for six intervals: two hours, four hours, one day, two days, four days, and ten workdays. Every row starts with the same seed, so what is being compared is the interval itself.

// interval.mjs — conflict and gate cost when the merge interval is scanned
import * as R from './repo.mjs';

const simulate = (hours, seed = 7391) => {
  const r = R.generator(seed);                     // every row starts with the same seed
  const T = hours * 60, horizon = R.DAY * R.DAY_MIN, n = R.RATE * hours;
  const trunk = [], events = [];                    // trunk: merged files {t, file, ...}
  for (let dev = 0; dev < R.DEVELOPERS; dev++) {
    for (let k = 0; k < horizon / T; k++) events.push({ dev, start: k * T + dev * (T / R.DEVELOPERS) });
  }
  events.sort((a, b) => a.start - b.start);
  let merges = 0, conflicts = 0, rounds = 0, errors = 0, semantic = 0, falseHold = 0, runs = 0, resolveMin = 0;

  for (const e of events) {
    const touched = new Set(), ifaceServices = new Set();
    while (touched.size < n) {                      // each branch touches n files
      const f = R.pickFile(r, e.dev);
      if (touched.has(f.name)) continue;
      touched.add(f.name);
      if (f.interface) ifaceServices.add(f.service);
    }
    merges++;
    let t = e.start + T, window = e.start, step = 0;
    while (true) {
      const rivals = trunk.filter((g) => g.t > window && g.t <= t && g.dev !== e.dev);
      const c = new Set(rivals.filter((g) => touched.has(g.file)).map((g) => g.file)).size;
      runs++;
      if (r() < R.FLAKY) { falseHold++; runs++; }    // flaky red: stops a clean change for nothing
      if (step === 0) {                              // interface mismatch with no textual conflict
        semantic += new Set(rivals.filter((g) => g.interface && ifaceServices.has(g.service) && !touched.has(g.file))
          .map((g) => g.file)).size;
      }
      if (c === 0 || step >= 5) break;
      conflicts += c; rounds++;
      for (let i = 0; i < c; i++) if (r() < R.ERROR_PROBABILITY) errors++;
      const duration = c * R.RESOLVE_MIN;
      resolveMin += duration; window = t; t += duration; step++;
    }
    for (const name of touched) {
      const f = R.files.find((x) => x.name === name);
      trunk.push({ t, file: name, dev: e.dev, service: f.service, interface: f.interface });
    }
  }
  const laterCost = (errors + semantic) * R.LATER_MIN;
  return { hours, n, merges, laterCost, conflicts, rounds, errors, semantic, falseHold, runs,
    resolveMin, runMin: runs * R.RUN_MIN, touches: trunk.length };
};

const pad = (x, w) => String(x).padStart(w);
const rows = [2, 4, 8, 16, 32, 80].map((h) => simulate(h));

const f2 = (x, w) => String(x.toFixed(2)).padStart(w);
console.log(`${'interval'.padEnd(9)}${pad('branch files', 14)}${pad('merges', 12)}${pad('conflicting', 13)}${pad('conflict/mrg', 14)}${pad('round/mrg', 11)}${pad('resolve errs', 14)}${pad('iface missed', 16)}`);
for (const x of rows) {
  console.log(`${(x.hours + ' hr').padEnd(9)}${pad(x.n, 14)}${pad(x.merges, 12)}${pad(x.conflicts, 13)}`
    + `${f2(x.conflicts / x.merges, 14)}${f2(x.rounds / x.merges, 11)}${pad(x.errors, 14)}${pad(x.semantic, 16)}`);
}
console.log(`\n${'interval'.padEnd(9)}${pad('runs', 7)}${pad('runner min', 15)}${pad('resolve min', 13)}${pad('late-found min', 16)}${pad('total min', 11)}${pad('false hold', 14)}`);
for (const x of rows) {
  console.log(`${(x.hours + ' hr').padEnd(9)}${pad(x.runs, 7)}${pad(x.runMin, 15)}${pad(x.resolveMin, 13)}${pad(x.laterCost, 16)}`
    + `${pad(x.runMin + x.resolveMin + x.laterCost, 11)}${pad(x.falseHold, 14)}`);
}
const best = rows.reduce((a, x) =>
  (x.runMin + x.resolveMin + x.laterCost < a.runMin + a.resolveMin + a.laterCost ? x : a));
console.log(`\ntouched files in every row ${rows.map((x) => x.touches).join('/')} (work is constant); `
  + `for seed 7391 the lowest total is in the ${best.hours}-hour interval`);
const at = (seed) => [2, 4, 8, 16, 32, 80].map((h) => simulate(h, seed))
  .reduce((a, x) => (x.runMin + x.resolveMin + x.laterCost < a.runMin + a.resolveMin + a.laterCost ? x : a));
console.log('other seeds: ' + [20260801, 4517, 66049, 131].map((s) => `${s} -> ${at(s).hours} hr`).join(', '));
interval   branch files      merges  conflicting  conflict/mrg  round/mrg  resolve errs    iface missed
2 hr                  1         640           74          0.12       0.12            12               3
4 hr                  2         320           90          0.28       0.27             4              10
8 hr                  4         160          205          1.28       0.81            22              17
16 hr                 8          80          320          4.00       0.97            31              26
32 hr                16          40          484         12.10       1.52            41              32
80 hr                40          16          654         40.88       1.63            55              43

interval    runs     runner min  resolve min  late-found min  total min    false hold
2 hr         731          10234         1480            2700      14414            17
4 hr         407           5698         1800            2520      10018             2
8 hr         297           4158         4100            7020      15278             8
16 hr        163           2282         6400           10260      18942             5
32 hr        103           1442         9680           13140      24262             2
80 hr         43            602        13080           17640      31322             1

touched files in every row 640/640/640/640/640/640 (work is constant); for seed 7391 the lowest total is in the 4-hour interval
other seeds: 20260801 -> 4 hr, 4517 -> 8 hr, 66049 -> 4 hr, 131 -> 4 hr

Conflict Does Not Grow Linearly

The first table’s real column is conflict/mrg. When branch lifetime grows forty-fold from two hours, conflicting files per merge climb from 0.12 to 40.88 — a factor of 354. The growth is not proportional to files per branch, because conflict is the intersection of two sets: both the branch’s own set and the total work landing on the main branch during that time grow. Two long-lived branches both raise the odds of touching the same hot files.

The round/mrg column does not, at first glance, keep pace with this growth: it climbs from 0.12 to 1.63 and saturates there. The reason is inside the measurement — a resolution round opens when the main branch moves while resolution is running. When branches are long-lived, merges are sparse, so the main branch usually does not move during resolution and one round is enough. The cost of a long branch does not show up in the round count; it shows up in round length: resolution time per merge is 2.3 model minutes on the two-hour branch and 818 model minutes on the ten-workday branch.

Errors born in resolution also grow with the interval: from 12 to 55. These are defects the merge itself produces; nobody wrote wrong code, they were born while two correct changes were reconciled by hand. The value of 4 in the four-hour row sits below its neighbors; because these numbers are small, that is seed noise, not a trend.

Frequency’s Own Cost

The second table gives the reverse direction. In the two-hour interval the pipeline runs 731 times and spends 10,234 model minutes of runner time; in the ten-workday interval, 43 runs and 602 minutes. Frequent merging is seventeen times more expensive to the machine. The number that has to be read alongside that column is false hold: a flaky red is a fixed probability per run, so as runs grow, false stops grow with them — 17 against 1. On a team that merges frequently, the number of changes the gate unfairly stops is seventeen times that of a team that merges rarely, and the urge to skip the gate is born right there.

The total column combines three items: runner time, resolution time, and the cost of late-found errors. The curve rises from both ends, and the lowest value sits at four hours, 10,018 minutes. Four of five seeds pick the same interval; one picks eight hours. The result is not “the more often, the better”: in the two-hour interval, runner time and false holds together inflate the total by more than 4,000 minutes; in the ten-workday interval, resolution and late finding push it up to 30,000 minutes. The optimum of the interval sits where the cost of a run intersects the cost of a conflict; as a run gets cheaper, the optimum shifts toward more frequent merging, and that is the real reason later lessons work to shorten pipeline time.

The Merge Gate’s Three Numbers

This gate is the entry gate to the main branch, and it is written with three numbers. What it holds: in the four-hour interval, 90 conflicting files become visible at merge time. All of this belongs only to this gate — no later gate in the pipeline can see a textual conflict. The build gate can only report a badly resolved merge as “broken”; it cannot say which two changes collided. The conflict information exists at merge time, then it is gone.

What it misses: interface mismatches. These are cases where two developers touch different files — no textual conflict — but one’s expectation diverges from what the other delivers. 10 of these pass through the gate in the four-hour interval, 43 in the ten-workday interval. Errors born in resolution also pass through the gate, because the gate does not check the resolution itself. What it falsely holds: 2 clean changes in the four-hour interval, 17 in the two-hour interval. The time it adds: 14 minutes of runner time per run and 20 minutes of human time per conflicting file; 23 minutes per change in the four-hour interval.

The gate cannot be defended unless these four numbers are read together. An evaluation that looks only at runner time finds frequent merging expensive and grows the interval; one that looks only at conflict count wants to shrink the interval to zero. Both are wrong, because the gate’s value is the difference between what it holds and what it misses, and that difference depends on the interval. The same gate holds 74 conflicts and misses 3 mismatches in the two-hour interval, and holds 654 while missing 43 in the ten-workday interval: holding more does not mean holding better — most of what it holds, the interval itself produced.

The two items it misses say this gate is not enough on its own, and that is the reason for the rest of the pipeline: the contract test is what will catch the interface mismatch, and the test gate is what will catch the error born in resolution. The order of the gates and what each one catches is the subject of the course’s later lessons.

Summary

  • When the merge interval grew from two hours to ten workdays, conflicting files per merge rose from 0.12 to 40.88, errors born in resolution from 12 to 55, and interface mismatches the gate missed from 3 to 43.
  • The resolution-round count saturated at 1.63; the cost of a long branch is not in the round count but in round length — resolution time per merge rose from 2.3 minutes to 818 model minutes.
  • Frequency’s own cost was counted: the two-hour interval takes 731 runs and 10,234 runner minutes, the ten-workday interval takes 43 runs and 602 minutes.
  • False holds are proportional to run count: 17 against 1; the urge to skip the gate is born from this number.
  • When the three items are added together, the lowest value sits in the four-hour interval (10,018 minutes), and four of five seeds point to the same place; as a run gets cheaper, the optimum shifts toward merging more often.

Next Step

This lesson’s gate sits at entry to the main branch, and its decision is the answer to a single question: should this change get in? The question of what happens after the main branch turns green was never touched. Is a green main branch something that can be put into production, or only something that is a candidate for production? The two answers build two different arrangements, and the difference between them collects into a single gate: the human approval standing between a green run and production. The next lesson runs the same pipeline in two modes and counts the real defects that approval catches, together with the time it holds up.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close