Skip to content
academia.sh

Lesson 19 / 27

Module Registries

Three modules pulled from a shared module registry, nine definitions and nine releases run a ten-week period in two setups; the version-pinned and unpinned setups are compared by unexpected change, broken definitions and fix-line count. The cost of pinning itself is also counted.

Published Updated

In this lesson

  • Counts how many times output changes with the definition text untouched, in an unpinned module dependency
  • Measures the spread of a breaking release with two numbers: how many definitions broke, how many lines it demanded to fix
  • Shows the cost of pinning through the count of definitions left on an old version and missed fixes
Contents

The previous lesson kept the module under a single hand: the author and the user were the same side, and a version upgrade was a single file edit. A module registry cuts that relationship. The registry is a shared repository that keeps versioned module text outside the definitions; here it is referred to not as a product or a service, but as a type. The module’s author publishes a release, the user pulls it, and the only link between them is a name written in the definition.

Whether to write a version next to that name is this lesson’s pair of measurements. In the unpinned setup, the definition writes only the module’s name, and the newest version in the registry is pulled at apply time. In the pinned setup, the definition writes the version too, and that version stands until someone changes it by hand. The same release flow and the same apply calendar are run in both setups; what gets measured is the gap between them.

The measurement is again on the regional measurement network: fictional software that collects water meter readings. No real infrastructure is provisioned, and no real registry service is connected to; what is measured is a model built with node.

SM31. The registry holds three modules and twelve versions; each version is written on one line, with its required inputs, defaulted inputs, and body. SM32. The period is ten weeks, and nine releases are written in advance: four patches, two minors, three breaking. There is no randomness. SM33. Nine definitions each call one module; apply weeks are written in advance, and eighteen applies run over the period. SM34. The unpinned setup pulls the newest version at apply time, the pinned setup pulls the version written in the definition. SM35. A breaking release stops the apply; the missing required input is written into the definition on the spot, and the line is counted. The fix rule is written in FIX. SM36. An unexpected change is the output being different from the previous one even though the definition text was not touched in that apply. In the pinned setup, two definitions are upgraded by hand during the period.

The Registry, the Release Flow and the Definitions

// registry.mjs — module registry (as a type): three modules, twelve versions, nine releases and nine consumer definitions.
export const MODULE = {                                // version -> "required | defaulted | body"
  "collection-point": {
    "1.0.0": "source-block | count=6 | cluster count=$count; network block=$source-block",
    "1.1.0": "source-block | count=6,monitoring=on | cluster count=$count; network block=$source-block; monitoring status=$monitoring",
    "1.1.1": "source-block | count=6,monitoring=on | cluster count=$count; network block=$source-block direction=inbound; monitoring status=$monitoring",
    "2.0.0": "network-block | count=6,monitoring=on | cluster count=$count; network block=$network-block direction=inbound; monitoring status=$monitoring",
    "2.0.1": "network-block | count=6,monitoring=on | cluster count=$count type=collector; network block=$network-block direction=inbound; monitoring status=$monitoring" },
  "measurement-queue": {
    "1.0.0": "name | wait=30 | queue name=$name wait=$wait",
    "1.0.1": "name | wait=30 | queue name=$name wait=$wait; dead-letter name=dead-$name",
    "1.1.0": "name | wait=30,cap=50000 | queue name=$name wait=$wait cap=$cap; dead-letter name=dead-$name",
    "2.0.0": "name,region | wait=30,cap=50000 | queue name=$name wait=$wait cap=$cap region=$region; dead-letter name=dead-$name" },
  "field-network": {
    "1.0.0": "port |  | network-rule port=$port",
    "2.0.0": "port,direction |  | network-rule port=$port direction=$direction",
    "2.0.1": "port,direction |  | network-rule port=$port direction=$direction logging=on" } };

export const RELEASE = ("1 measurement-queue 1.0.1 patch|2 collection-point 1.1.0 minor|3 field-network 2.0.0 breaking|" +
  "4 collection-point 1.1.1 patch|5 measurement-queue 1.1.0 minor|6 collection-point 2.0.0 breaking|" +
  "7 measurement-queue 2.0.0 breaking|8 collection-point 2.0.1 patch|9 field-network 2.0.1 patch")
  .split("|").map((s) => s.split(" ")).map(([h, m, s, t]) => [+h, m, s, t]);

export const DEFINITION = {                            // "module | passed | pinned version | apply weeks"
  "north-point": "collection-point | source-block=10.20.0.0/16 count=4 | 1.0.0 | 2,6,9",
  "west-point": "collection-point | source-block=10.30.0.0/16 | 1.0.0 | 3,7",
  "east-point": "collection-point | source-block=10.40.0.0/16 count=8 | 1.0.0 | 1,5,10",
  "south-point": "collection-point | source-block=10.50.0.0/16 | 1.0.0 | 8",
  "north-queue": "measurement-queue | name=north-read | 1.0.0 | 2,8",
  "west-queue": "measurement-queue | name=west-read wait=60 | 1.0.0 | 4,9",
  "central-queue": "measurement-queue | name=central-read | 1.0.0 | 6,10",
  "field-inbound": "field-network | port=8080 | 1.0.0 | 4,10",
  "lab-inbound": "field-network | port=9443 | 1.0.0 | 5" };
export const UPGRADE = [[7, "west-point", "1.1.1"], [9, "north-queue", "1.1.0"]];   // manual, pinned setup
export const FIX = {                                   // the line written into the definition after a breaking release
  "network-block": (a) => a["source-block"], direction: () => "inbound", region: (a) => a.name.split("-")[0] };

The three modules’ twelve versions are read together with the release flow. collection-point renames its required input to network-block in the sixth week, field-network demands a new required input called direction in the third week, measurement-queue demands region in the seventh week. All three are breaking releases, and the cost of all three shows up in the definitions that use the registry.

Two Setups, the Same Ten Weeks

// period.mjs — the same release flow and the same apply calendar are run in two setups; the gap is counted.
import { MODULE, RELEASE, DEFINITION, UPGRADE, FIX } from "./registry.mjs";

const kv = (s) => Object.fromEntries(s.trim() ? s.trim().split(/[ ,]+/).map((x) => x.split("=")) : []);
const DEF = Object.fromEntries(Object.entries(DEFINITION).map(([t, s]) => {
  const [m, g, p, h] = s.split(" | ");
  return [t, { m, g: kv(g), p, h: h.split(",").map(Number) }];
}));
const resolve = (s) => { const [z, v, b] = s.split(" | ");
  return { req: z.trim() ? z.trim().split(",") : [], vars: kv(v), body: b.split("; ") }; };
const latest = (m, h) => (RELEASE.filter((y) => y[1] === m && y[0] <= h).at(-1) ?? [, , "1.0.0"])[2];
const render = (mv, a) => mv.body.map((l) => l.replace(/\$([\w-]+)/g, (_, x) => a[x])).join("; ");
const diff = (a, b) => {                               // words added/changed between two outputs
  const x = a.split(/[ ;]+/), y = b.split(/[ ;]+/);
  return y.filter((w) => !x.includes(w)).join(" ") || "(removed)";
};

const run = (pinned) => {
  const args = {}, pin = {}, prevOut = {}, ran = {}, last = {}, prevVer = {}, unexpected = [];
  let unexpectedCount = 0, brokenDefs = new Set(), brokenLines = 0, changed = 0;
  for (const t in DEF) { args[t] = { ...DEF[t].g }; pin[t] = DEF[t].p; ran[t] = new Set(); }
  for (let h = 1; h <= 10; h++) {
    if (pinned) for (const [hy, t, s] of UPGRADE) if (hy === h) { pin[t] = s; args[t].$fixed = 1; }
    for (const t in DEF) {
      if (!DEF[t].h.includes(h)) continue;
      const s = pinned ? pin[t] : latest(DEF[t].m, h);
      const mv = resolve(MODULE[DEF[t].m][s]), full = { ...mv.vars, ...args[t] };
      const missing = mv.req.filter((a) => !(a in full));
      let fixed = args[t].$fixed;
      if (missing.length) {                            // breaking release: the definition is fixed on the spot
        for (const e of missing) { args[t][e] = FIX[e](full); full[e] = args[t][e]; }
        brokenDefs.add(t); brokenLines += missing.length; fixed = 1;
      }
      const c = render(mv, full);
      ran[t].add(s);
      if (prevOut[t] !== undefined && prevOut[t] !== c) {
        changed++;
        if (!fixed) { unexpectedCount++; unexpected.push([h, t, prevVer[t], s, diff(prevOut[t], c)]); }
      }
      prevOut[t] = c; last[t] = s; prevVer[t] = s; delete args[t].$fixed;
    }
  }
  return { unexpectedCount, brokenDefs, brokenLines, changed, pin, ran, last, unexpected };
};

const UNPINNED = run(false), PINNED = run(true), total = Object.keys(DEF).length;
const versionsOf = (m) => RELEASE.filter((y) => y[1] === m);
console.log(`period 10 weeks | releases ${RELEASE.length}: ` + ["patch", "minor", "breaking"]
  .map((k) => `${k} ${RELEASE.filter((y) => y[3] === k).length}`).join(", ") +
  ` | definitions ${total} | applies ${Object.values(DEF).reduce((a, b) => a + b.h.length, 0)}`);
const mismatched = Object.keys(DEF).filter((t) => PINNED.pin[t] !== PINNED.last[t]).length;   // pinned version is not what is running
for (const [label, r, y] of [["unpinned ", UNPINNED, total], ["pinned   ", PINNED, mismatched]])
  console.log(`${label} | output changes ${r.changed} | unexpected changes ${r.unexpectedCount}` +
    ` | broken definitions ${r.brokenDefs.size} | fix lines ${r.brokenLines} | distinct versions run ` +
    `${Object.values(r.ran).reduce((a, b) => a + b.size, 0)} | definitions the record does not say what is running for ${y}/${total}`);

for (const [h, t, e, s, d] of UNPINNED.unexpected)     // outputs that changed with the definition untouched
  console.log(`  unexpected   h${String(h).padEnd(2)} ${t.padEnd(14)} ${e} -> ${s}  added: ${d}`);
for (const [h, m, s, kind] of RELEASE) {               // how many users a release reached
  const u = Object.keys(DEF).filter((t) => DEF[t].m === m && UNPINNED.ran[t].has(s));
  const b = Object.keys(DEF).filter((t) => DEF[t].m === m && PINNED.ran[t].has(s));
  console.log(`  h${String(h).padEnd(2)} ${m.padEnd(17)} ${s} ${kind.padEnd(9)} definitions using it ${Object.keys(DEF)
    .filter((t) => DEF[t].m === m).length} | reached: unpinned ${u.length}, pinned ${b.length}`);
}

let behind = 0, missedPatches = 0, stale = 0;          // the cost of pinning: at the end of the period
for (const t in DEF) {
  const l = versionsOf(DEF[t].m), after = l.slice(l.findIndex((y) => y[2] === PINNED.pin[t]) + 1);
  behind += after.length; missedPatches += after.filter((y) => y[3] === "patch").length;
  if (after.length) stale++;
  console.log(`  ${t.padEnd(18)} pinned ${PINNED.pin[t]}  ran ${PINNED.last[t]}  newest ${l.at(-1)[2]}` +
    `  behind ${after.length}  missed patches ${after.filter((y) => y[3] === "patch").length}`);
}
const used = new Set(Object.keys(DEF).map((t) => `${DEF[t].m} ${PINNED.pin[t]}`));
const outsideLines = [...used].reduce((a, k) => {
  const [m, s] = k.split(" "), v = resolve(MODULE[m][s]);
  return a + v.req.length + Object.keys(v.vars).length + v.body.length;
}, 0);
console.log(`definitions left on an old version ${stale}/${total} | total behind ${behind} versions | missed patches ${missedPatches}` +
  ` | definitions in the repo ${Object.values(DEF).reduce((a, d) => a + 3 + Object.keys(d.g).length, 0)} lines` +
  ` | depends on ${used.size} module versions, ${outsideLines} lines outside the repo`);
period 10 weeks | releases 9: patch 4, minor 2, breaking 3 | definitions 9 | applies 18
unpinned  | output changes 9 | unexpected changes 3 | broken definitions 9 | fix lines 9 | distinct versions run 18 | definitions the record does not say what is running for 9/9
pinned    | output changes 1 | unexpected changes 0 | broken definitions 0 | fix lines 0 | distinct versions run 10 | definitions the record does not say what is running for 1/9
  unexpected   h5  east-point     1.0.0 -> 1.1.1  added: direction=inbound monitoring status=on
  unexpected   h9  north-point    2.0.0 -> 2.0.1  added: type=collector
  unexpected   h10 field-inbound  2.0.0 -> 2.0.1  added: logging=on
  h1  measurement-queue 1.0.1 patch     definitions using it 3 | reached: unpinned 2, pinned 0
  h2  collection-point  1.1.0 minor     definitions using it 4 | reached: unpinned 2, pinned 0
  h3  field-network     2.0.0 breaking  definitions using it 2 | reached: unpinned 2, pinned 0
  h4  collection-point  1.1.1 patch     definitions using it 4 | reached: unpinned 1, pinned 1
  h5  measurement-queue 1.1.0 minor     definitions using it 3 | reached: unpinned 1, pinned 0
  h6  collection-point  2.0.0 breaking  definitions using it 4 | reached: unpinned 2, pinned 0
  h7  measurement-queue 2.0.0 breaking  definitions using it 3 | reached: unpinned 3, pinned 0
  h8  collection-point  2.0.1 patch     definitions using it 4 | reached: unpinned 3, pinned 0
  h9  field-network     2.0.1 patch     definitions using it 2 | reached: unpinned 1, pinned 0
  north-point        pinned 1.0.0  ran 1.0.0  newest 2.0.1  behind 4  missed patches 2
  west-point         pinned 1.1.1  ran 1.1.1  newest 2.0.1  behind 2  missed patches 1
  east-point         pinned 1.0.0  ran 1.0.0  newest 2.0.1  behind 4  missed patches 2
  south-point        pinned 1.0.0  ran 1.0.0  newest 2.0.1  behind 4  missed patches 2
  north-queue        pinned 1.1.0  ran 1.0.0  newest 2.0.0  behind 1  missed patches 0
  west-queue         pinned 1.0.0  ran 1.0.0  newest 2.0.0  behind 3  missed patches 1
  central-queue      pinned 1.0.0  ran 1.0.0  newest 2.0.0  behind 3  missed patches 1
  field-inbound      pinned 1.0.0  ran 1.0.0  newest 2.0.1  behind 2  missed patches 1
  lab-inbound        pinned 1.0.0  ran 1.0.0  newest 2.0.1  behind 2  missed patches 1
definitions left on an old version 9/9 | total behind 25 versions | missed patches 11 | definitions in the repo 39 lines | depends on 5 module versions, 20 lines outside the repo

All eighteen of the eighteen applies ran in both setups; the difference is in what ran. In the unpinned setup, output changed nine times; in the pinned setup, once. That one time is also a manual upgrade; no output changed without someone wanting it.

Three of the nine changes are unexpected changes: the definition text was never touched in that apply, yet the produced resource came out different from the previous one. The remaining six came right after breaking releases, and the definition had already been fixed by that point. The breakdown of the three unexpected ones shows what they have in common: east-point jumped two versions at once in the fifth week and gained a network field plus a monitoring line, north-point picked up type=collector on its machine cluster in the ninth week, field-inbound picked up logging=on on its network rule in the tenth week. All three are the result of a release that shows up nowhere in the record, all three applied silently, and none of them raised an error.

The spread of breaking releases is the second number. Three breaking releases broke nine of nine definitions in the unpinned setup and demanded nine lines of fixing. Three releases, nine fixes: the cost of one breaking release is paid once on the publishing side, and once per user on the consuming side. In the pinned setup, the same three releases broke no definition and demanded zero lines of fixing — but that does not mean the cost was erased, it means it was deferred.

The per-release breakdown gives how many users each release reached. None of the nine releases reached every definition using that module: the widest reach is three of three, the narrowest is one of four. Who a release reaches is decided not by the publishing side, but by the consuming side’s apply calendar. In the pinned setup, eight of the same nine releases reached no user at all; the one release that did reach someone is the patch the manually upgraded definition pulled. In the unpinned setup, nine definitions ran eighteen distinct versions over the period; in the pinned setup, ten: two per definition against 1.1.

The Cost of Pinning

At the end of the period, the table flips. In the pinned setup, nine of nine definitions are on an old version; they are twenty-five versions behind in total, and eleven patches never reached them at all. A patch is a bug fix; pinning does not only stop unwanted change, it stops wanted fixes too.

The distribution shows how the deferral accumulates. Five of the seven definitions that were never upgraded are three to four versions behind, two are two versions behind; west-point, upgraded once during the period, is two versions behind and missing one patch. An upgrade pulls a definition forward, but because the release flow does not stop, the distance reopens right away. The cost of pinning is not a one-time delay, it is a gap that keeps growing.

The interest on that deferral is countable too: the nine lines of fixing not paid today are not erased; if those nine definitions are ever upgraded, they will meet all three breaking releases at once.

What the Record Does Not Say

The third number concerns the record itself. In the unpinned setup, the definition writes no version at all; in nine of nine definitions, the record does not describe what is running. Reading the definition file cannot tell you what resource will be produced, because part of the text lives somewhere else, and that place can change.

Pinning brings this down to one, but not to zero. The north-queue row shows the gap: pinned version 1.1.0, running version 1.0.0. The version was upgraded in the ninth week, but that definition had no other apply during the period. The record says the new version, the field still holds the old one. Pinning does not eliminate the gap between record and reality; it moves the gap from release time to apply time.

The last line gives the size of the dependency. The repository’s nine definitions are thirty-nine lines, and they depend on five separate module versions, twenty lines of text outside the repo in total. In the pinned setup, which version of those twenty lines is in effect is written in the definition; in the unpinned setup it is not written anywhere, and it can change shape without a single record in the repository changing.

Summary

  • The same 18 applies ran in both setups: output changed 9 times in the unpinned setup, 1 time in the pinned setup; that 1 change is also a manual upgrade.
  • 3 of the 9 changes are unexpected changes — the definition text was untouched, the output changed, and none of them raised an error.
  • 3 breaking releases broke 9 definitions in the unpinned setup and demanded 9 lines of fixing; in the pinned setup, 0 definitions broke, but the cost was not erased, it was deferred.
  • The cost of pinning is seen at the end of the period: 9 of 9 definitions are on an old version, 25 versions behind in total, and 11 patches never arrived.
  • In the unpinned setup, 9 of 9 definitions have a record that does not say what is running; in the pinned setup, 1 does not — because the written version was never applied, record and reality diverged again.

Next Step

Text coming from a module registry now behaves like part of the definition: it takes inputs, produces resources, gives outputs. In this lesson, all of those inputs were harmless — a network block, a port number, a queue name. What would happen if one of them were a data store password? A value passes from definition to module, from module to resource, from resource to the state file; along the way it also passes through the plan output, the log, and the pipeline record. The next lesson sends a fictional sensitive value down that path and counts, with a trace scanner written in node, how many places it shows up; then that value is stamped sensitive and the scan is run again — counting how many places it is now hidden, and how many places it still stands.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close