Skip to content
academia.sh

Lesson 05 / 27

Drift

A drift detector is written: the record is read, the actual state is read from disk, the difference is worked out item by item, and each difference's source is separated. The drift accumulated over a thirty-day period, the detection delay by scan frequency, the drift that never closes because it falls outside the record's scope, and the case where a rollback breaks something that works are counted.

Published Updated

In this lesson

  • Writes a working drift detector: reads the record, reads the actual state from disk, works out the difference item by item
  • Separates the source of the drift into three: manual change, out-of-record resource, provider default
  • Counts the detection delay by scan frequency, and the case where a rollback breaks something that works
Contents

The previous four lessons said the same thing from four places. The two setups diverged on nine items; after partial failure, the record and the environment disagreed on three items; a rebuild wiped hotfixes; after reprovisioning, nine items stayed for fifteen minutes at a value the record never said. All of them share the same name.

Drift is the difference between what the record says and reality. This lesson measures it directly: a detector is written — the record is read, the actual state is read from disk, the difference is worked out item by item, and each difference’s source is separated. Then a thirty-day period is run, and four things are counted: the drift that accumulates, the detection delay by scan frequency, the drift that never closes because it falls outside the record’s scope, and the cost of fixing it. The last is at least as important as the other three: rolling back a drift sometimes breaks something that works.

One distinction is set up from the start. The reconciliation loop measured while Cluster Architecture was covered in M22/K03 ran continuously, and a difference closed the moment it was born; here, an apply is triggered, and drift accumulates between two triggers. Every number in this lesson is born in that gap.

The environment is again the staging environment of the regional measurement network, and it is fictional. The record and the actual state are genuinely written to disk and the detector genuinely runs; the thirty-day period is modeled with node, and the generator is self-written.

IF25. The record writes twenty items. The actual state is a directory tree and twenty-five files; the detector reads it not from the record but from disk. IF26. The provider’s documented defaults are in a separate file. If an item not in the record is on that list, its source is a provider default; if not, it is an out-of-record resource. IF27. Over the thirty-day period, events are produced by the generator (seed 4471903, the generator is self-written); the daily event count is zero, one, or two. IF28. An event’s source is a manual change, an out-of-record resource, or a provider default. Only a manual change falls within the record’s scope; the others are not closed by an apply. IF29. Apply is triggered: on days 1, 12, and 25. A scan is separate from apply — it detects, it does not close. IF30. Some of the manual changes are live fixes made to close an incident; when rolled back, the problem they closed comes back.

The Record and Reality

#!/usr/bin/env bash
# Record and actual environment: the record is one file, the actual state is a directory tree. Both are written to disk.
set -e
rm -rf actual

cat > record.infra <<'SON'
resource network "measurement-network" { block = "10.40.0.0/16"  subnet_count = 3  private = "true" }
resource machine "collector" { count = 3  size = "medium"  disk_gb = 40  image = "base-7" }
resource machine "verifier" { count = 2  size = "small"  disk_gb = 20  image = "base-7" }
resource queue "reading" { partitions = 6  retention_hours = 72  dead_letter = "open" }
resource data_store "measurement" { size_gb = 200  backup_days = 14  version = "5.2" }
resource object_store "archive" { class = "sparse"  versioning = "open"  lifecycle_days = 365 }
SON

# The provider's documented defaults: fields the record does not write, that arise on their own.
cat > defaults.txt <<'SON'
network.measurement-network.egress_path
queue.reading.encryption
data_store.measurement.maintenance_window
object_store.archive.access_rule
SON

write() { mkdir -p "actual/$(dirname "$1")"; printf '%s\n' "$2" > "actual/$1"; }
# Fourteen items that match the record
write network/measurement-network/block 10.40.0.0/16;    write network/measurement-network/private true
write machine/collector/size medium;                     write machine/collector/image base-7
write machine/verifier/count 2;                           write machine/verifier/size small
write machine/verifier/disk_gb 20;                          write machine/verifier/image base-7
write queue/reading/partitions 6;                            write queue/reading/dead_letter open
write data_store/measurement/size_gb 200;                     write data_store/measurement/version 5.2
write object_store/archive/versioning open;                    write object_store/archive/lifecycle_days 365
# Five items changed by hand (different from the record's value)
write network/measurement-network/subnet_count 4;               write machine/collector/count 5
write machine/collector/disk_gb 80;                                write queue/reading/retention_hours 24
write data_store/measurement/backup_days 7
# object_store/archive/class deleted: in the record, not in reality
# Provider default (three items) and out-of-record resource (three items)
write network/measurement-network/egress_path open;                 write queue/reading/encryption provider-key
write data_store/measurement/maintenance_window sunday-03:00
write security/temporary/ingress 0.0.0.0-8443;                       write dns_record/collector/internal collector.internal
write machine/diagnostics/count 1
printf 'record %s lines   actual %s files\n' "$(wc -l < record.infra | tr -d ' ')" \
  "$(find actual -type f | wc -l | tr -d ' ')"
record 6 lines   actual 25 files

The Difference, Item by Item

// drift.mjs — reads the record, reads the actual state from disk, works out the difference item by item. Fixes nothing.
import { readFileSync, readdirSync, statSync } from "node:fs";

const BLOCK = /^resource\s+(\w+)\s+"([\w-]+)"\s*\{(.*)\}$/;
const record = new Map();
for (const s of readFileSync("record.infra", "utf8").split("\n")) {
  const m = BLOCK.exec(s.trim());
  if (m) for (const x of m[3].matchAll(/(\w+)\s*=\s*("[^"]*"|[\w./-]+)/g))
    record.set(`${m[1]}.${m[2]}.${x[1]}`, x[2].replaceAll('"', ""));
}
const providerDefault = new Set(readFileSync("defaults.txt", "utf8").trim().split("\n"));

const actual = new Map();                       // actual state is read from disk, not from the record
const walk = (dir, prefix = []) => {
  for (const name of readdirSync(dir).sort()) {
    const path = `${dir}/${name}`;
    if (statSync(path).isDirectory()) walk(path, [...prefix, name]);
    else actual.set([...prefix, name].join("."), readFileSync(path, "utf8").trim());
  }
};
walk("actual");

const all = [...new Set([...record.keys(), ...actual.keys()])].sort();
const drift = all.filter((k) => record.get(k) !== actual.get(k)).map((k) => ({ k,
  source: !actual.has(k) ? "missing item" : !record.has(k)
    ? (providerDefault.has(k) ? "provider default" : "out-of-record resource") : "manual change" }));
const countBy = (t) => drift.filter((f) => f.source === t).length;

console.log(`record ${record.size} items   actual ${actual.size} items   union ${all.length}`);
console.log(`drift ${drift.length} items` +
  `   manual ${countBy("manual change")}   missing ${countBy("missing item")}` +
  `   out-of-record ${countBy("out-of-record resource")}   default ${countBy("provider default")}`);
for (const f of drift)
  console.log(`  ${f.source.padEnd(22)} ${f.k.padEnd(43)} record "${record.get(f.k) ?? "-"}"` +
    `  actual "${actual.get(f.k) ?? "-"}"`);
const inScope = drift.filter((f) => record.has(f.k)).length;
console.log(`drift in the record's scope, i.e. closable by an apply ${inScope}` +
  `   out of scope ${drift.length - inScope}`);
record 20 items   actual 25 items   union 26
drift 12 items   manual 5   missing 1   out-of-record 3   default 3
  manual change          data_store.measurement.backup_days          record "14"  actual "7"
  provider default       data_store.measurement.maintenance_window   record "-"  actual "sunday-03:00"
  out-of-record resource dns_record.collector.internal               record "-"  actual "collector.internal"
  manual change          machine.collector.count                     record "3"  actual "5"
  manual change          machine.collector.disk_gb                   record "40"  actual "80"
  out-of-record resource machine.diagnostics.count                   record "-"  actual "1"
  provider default       network.measurement-network.egress_path     record "-"  actual "open"
  manual change          network.measurement-network.subnet_count    record "3"  actual "4"
  missing item           object_store.archive.class                  record "sparse"  actual "-"
  provider default       queue.reading.encryption                    record "-"  actual "provider-key"
  manual change          queue.reading.retention_hours               record "72"  actual "24"
  out-of-record resource security.temporary.ingress                  record "-"  actual "0.0.0.0-8443"
drift in the record's scope, i.e. closable by an apply 6   out of scope 6

Twelve items drifted, and the last line shows where the measure actually sits: six of the twelve fall within the record’s scope. For the remaining six items, running an apply changes nothing, because the record says nothing about them — three are fields the provider opens on its own, three are resources created without a record ever being written. The only reason these are visible at all is that the detector is two-directional: it does not just scan the items in the record, it walks the whole actual state and writes down the ones with no counterpart in the record too.

The source distinction is not a labeling exercise, it is an action distinction. A manual change is closed by an apply. A missing item comes back with an apply. An out-of-record resource is only closed by being brought into the record or being deleted; a provider default neither closes nor should it — all that needs to be known is that the record is silent about it. Four separate kinds of difference, four separate decisions.

Over a Period

// period.mjs — 30-day drift accumulation, detection delay, and the cost of fixing it. Model.
const SEED = 4471903, DAYS = 30, APPLY = [1, 12, 25];   // apply is triggered, it does not run continuously
const generator = (t) => () => (t = (t * 1103515245 + 12345) % 2147483648) / 2147483648;
const r = generator(SEED);

const events = [];
for (let d = 1; d <= DAYS; d++)
  for (let n = (r() < 0.55) + (r() < 0.18); n > 0; n--) {
    const p = r();
    const source = p < 0.45 ? "manual" : p < 0.72 ? "out-of-record" : "default";
    events.push({ d, source, inScope: source === "manual", live: source === "manual" && r() < 0.35 });
  }
const countBy = (t) => events.filter((o) => o.source === t).length;
const inScope = events.filter((o) => o.inScope);
console.log(`seed ${SEED}   days ${DAYS}   events ${events.length}   apply days` +
  ` ${APPLY.join(", ")}`);
console.log(`source of the drift   manual ${countBy("manual")}   out-of-record ${countBy("out-of-record")}` +
  `   provider default ${countBy("default")}   in scope ${inScope.length}`);

// Two regimes: apply is triggered (three days) and continuous reconciliation (every day). Both close only what is in scope.
const regime = (days) => {
  const total = [], scope = [];
  let closed = 0;
  for (let d = 1; d <= DAYS; d++) {
    if (days.includes(d)) closed = inScope.filter((o) => o.d <= d).length;
    scope.push(inScope.filter((o) => o.d <= d).length - closed);
    total.push(events.filter((o) => o.d <= d).length - closed);
  }
  const area = (x) => x.reduce((a, b) => a + b, 0);
  return { total, scope, areaT: area(total), areaS: area(scope) };
};
const A = regime(APPLY), B = regime([...Array(DAYS)].map((_, i) => i + 1));
console.log(`\nopen drift (end of day, ${DAYS} days)`);
console.log(`  triggered apply     total   ${A.total.join(" ")}`);
console.log(`  triggered apply     scope   ${A.scope.join(" ")}`);
console.log(`  continuous reconcile total  ${B.total.join(" ")}`);
console.log(`  day-item area   in-scope drift: triggered ${A.areaS}   continuous ${B.areaS}`);
console.log(`  day-item area   all drift:      triggered ${A.areaT}   continuous ${B.areaT}` +
  `   difference ${A.areaT - B.areaT}`);

console.log(`\ndetection delay (only the ${inScope.length} in-scope drift; a scan detects, it does not close)`);
for (const n of [1, 3, 7, 30]) {
  const delay = inScope
    .map((o) => { for (let d = o.d; d <= DAYS; d++) if (d % n === 0) return d - o.d; return null; })
    .filter((x) => x !== null);
  console.log(`  scan every ${String(n).padStart(2)} days   average` +
    ` ${(delay.reduce((a, b) => a + b, 0) / delay.length).toFixed(1)} days` +
    `   worst ${Math.max(...delay)} days   detected ${delay.length}/${inScope.length}`);
}

const rolledBack = inScope.filter((o) => o.d <= APPLY[APPLY.length - 1]);
const broken = rolledBack.filter((o) => o.live);
console.log(`\nthe cost of fixing it`);
console.log(`  drift rolled back ${rolledBack.length}   of these a live fix` +
  ` ${broken.length}   rolling it back breaks something that works`);
console.log(`  closed for good ${rolledBack.length - broken.length}` +
  `   came back ${broken.length} (${(100 * broken.length / rolledBack.length).toFixed(0)}%)`);
console.log(`  out-of-scope ${events.length - inScope.length} drift was closed by no apply;` +
  ` all of it is still standing at the end of the period`);
seed 4471903   days 30   events 21   apply days 1, 12, 25
source of the drift   manual 9   out-of-record 6   provider default 6   in scope 9

open drift (end of day, 30 days)
  triggered apply     total   1 1 2 2 2 3 3 3 4 5 6 3 5 6 7 8 8 8 9 9 10 11 12 13 9 10 10 11 11 13
  triggered apply     scope   0 0 1 1 1 1 1 1 2 3 3 0 1 2 2 2 2 2 2 2 2 2 3 4 0 1 1 1 1 1
  continuous reconcile total  1 1 1 1 1 2 2 2 2 2 3 3 4 4 5 6 6 6 7 7 8 9 9 9 9 9 9 10 10 12
  day-item area   in-scope drift: triggered 45   continuous 0
  day-item area   all drift:      triggered 205   continuous 160   difference 45

detection delay (only the 9 in-scope drift; a scan detects, it does not close)
  scan every  1 days   average 0.0 days   worst 0 days   detected 9/9
  scan every  3 days   average 1.0 days   worst 2 days   detected 9/9
  scan every  7 days   average 3.1 days   worst 5 days   detected 9/9
  scan every 30 days   average 13.7 days   worst 27 days   detected 9/9

the cost of fixing it
  drift rolled back 8   of these a live fix 2   rolling it back breaks something that works
  closed for good 6   came back 2 (25%)
  out-of-scope 12 drift was closed by no apply; all of it is still standing at the end of the period

Twenty-one drift events were born over thirty days, and nine of them fell within the record’s scope. The difference between the two series reduces what a triggered apply means to a single line: in-scope drift stayed open for forty-five day-items in the triggered regime, zero in continuous reconciliation. The three trigger days show up clearly in the series — on days 1, 12, and 25, the scope line drops to zero, then climbs again. Drift does not disappear, it is reborn between every trigger; what accumulates is not drift itself, it is unclosed drift.

The total line, on the other hand, keeps climbing in both regimes and stops at thirteen and twelve on day thirty. The difference between them is only forty-five day-items, and that comes from the nine in-scope items. The remaining twelve drift events closed under neither regime: running apply continuously does not make them visible, because the reason they stay invisible is not frequency, it is scope. This shows how far the thing seen as five items in the topic’s first lesson has grown over the course of a period.

Detection Delay and the Cost of Fixing It

The scan table measures a separate decision. A scan is independent of apply: it makes a drift visible without closing it. A daily scan zeroes out the delay, a scan every three days raises it to an average of one day, a weekly scan to 3.1 days, a monthly scan to 13.7 days — and on a monthly scan the worst delay is twenty-seven days. Twenty-seven days is how long an item runs at a value the record never said, with nobody knowing. Choosing a scan frequency is choosing this number.

The last table carries this lesson’s most counterintuitive number. By day twenty-five, eight drift events were rolled back, and two were live fixes: changes made by hand to close an incident, that genuinely resolved the problem. When rolled back, the problem they closed came back, and both were redone on later days. The drift rollback closed for good is six, the drift that came back is two — twenty-five percent.

These two items show that drift cannot be read in only one direction. Drift does not mean “the record is right, reality is wrong”; it only means the two have diverged. Divergence has two interpretations, and which one is correct cannot be read from the difference itself: either the record should be updated and the fix written back into it, or reality should be rolled back. An automated apply always picks the second, because it has no other option. The cost of the record staying silent about an item was invisibility; the cost of the record staying stale about an item is breaking something that works.

Summary

  • The detector compared 26 items and found 12 drifted: 5 manual changes, 1 missing item, 3 out-of-record resources, 3 provider defaults; only 6 of these can be closed by an apply.
  • 21 drift events were born over thirty days; 9 fell within the record’s scope, 12 were still open at the end of the period and closed under no regime.
  • Triggered apply left in-scope drift open for 45 day-items, continuous reconciliation 0; the difference comes from frequency, the 12 items that never close come from scope.
  • Scan frequency determines the delay directly: 0.0 days daily, 3.1 days weekly, 13.7 days monthly on average, and 27 days worst case.
  • 2 of the 8 rolled-back drift events were live fixes, and rolling them back broke something that works; the rollback closed 6 for good, 2 came back.

Next Step

At this point, drift is something countable. The record’s scope is known, the item outside that scope can be written down by name, the source of the difference can be split into four, the detection delay can be tied to scan frequency, and fixing it has been measured to carry a cost. The whole measurement leans on a single assumption: that a readable record exists somewhere.

The record itself was never addressed. Across five lessons, the record was always found ready-made — a six-line file, a list counting its items by their values. How it is written, what structures it is built from, how one resource attaches to another, how the same definition gets written for three environments without repeating itself, where a value comes from, and what it will show before a change is applied — none of this was asked. The next topic starts by writing the record itself.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close