Skip to content
academia.sh

Lesson 02 / 27

Declarative and Imperative Approaches

The same nine-item target is written in two forms — a four-line desired-state definition and a nine-line step list — and run so that the third step fails. The items that reach the target after partial failure, the no-op writes and errors produced on a rerun, and the lines written when the target changes are counted.

Published Updated

In this lesson

  • Counts what partial failure leaves behind in both forms: items applied, steps never attempted, dependencies skipped
  • Measures the no-op writes and errors produced when the same recipe is rerun
  • Counts the lines written in both forms when the target changes, and shows the cost of making the imperative list rerunnable
Contents

The previous lesson wrote the record as a list of items: each line said what a resource should be. The same environment can also be written as a list of steps — first open the network, then open the store, then attach the machine — and that list also lives in a file, is versioned, is reviewed. Both forms are records, and both ask for the same extra steps.

The declarative recipe writes the desired state; the imperative recipe writes the steps that lead there. The Infrastructure Provisioning Models lesson in the DevOps Culture and Fundamentals course (M22/K01) compared manual, scripted, and declarative provisioning, and showed why drift can only be counted in the declarative model; that comparison is not repeated here. This lesson carries the measurement to a different axis: partial failure. The same target is written in two forms, run so that the third step fails, and three things are counted — the items that reach the target after the failure, the work produced when the same recipe is rerun, and the lines written when the target changes.

The environment is again the regional measurement network: fictional software that collects meter readings, verifies them, and converts them into invoices. Infrastructure is not provisioned; the environment, the operations, and the quota are modeled with node. The recipe files are genuinely written to disk.

IF7. The target is four resources and nine items; the resource itself is an item too, and so are its attributes. IF8. The imperative recipe is a nine-line step list, the declarative recipe a four-line definition; both target the same nine items. IF9. create errors on an existing resource; set written with the same value counts as a no-op write. Whether idempotence holds is decided by these two rules. IF10. On the first run the data store quota is zero and the third step fails; the quota is raised to one before the second run. The error is written in advance, there is no randomness. IF11. The declarative applier applies items in dependency order; an item whose dependency is not met is skipped, not counted as an error. IF12. The imperative runner has two modes: stop on the first error, or skip the error and continue.

Same Target, Two Forms

#!/usr/bin/env bash
# Two forms of the same target: a declarative definition and an imperative step list. Second versions are written too.
set -e

cat > target.infra <<'SON'
resource network "measurement-network" { block = "10.40.0.0/16" }
resource data_store "measurement-store" { size_gb = 200 }
resource machine "collector" { count = 3  attached = "data_store.measurement-store" }
resource queue "reading-queue" { partitions = 6 }
SON

cat > steps.txt <<'SON'
create network.measurement-network
set network.measurement-network.block 10.40.0.0/16
create data_store.measurement-store
set data_store.measurement-store.size_gb 200
create machine.collector
set machine.collector.count 3
create queue.reading-queue
set queue.reading-queue.partitions 6
attach machine.collector data_store.measurement-store
SON

# The target changes: collector 3 -> 5, queue partitions 6 -> 12, an object store is added.
cat > target-v2.infra <<'SON'
resource network "measurement-network" { block = "10.40.0.0/16" }
resource data_store "measurement-store" { size_gb = 200 }
resource machine "collector" { count = 5  attached = "data_store.measurement-store" }
resource queue "reading-queue" { partitions = 12 }
resource object_store "archive" { class = "sparse" }
SON

cat > steps-v2.txt <<'SON'
skip-if-exists create network.measurement-network
set network.measurement-network.block 10.40.0.0/16
skip-if-exists create data_store.measurement-store
set data_store.measurement-store.size_gb 200
skip-if-exists create machine.collector
set machine.collector.count 5
skip-if-exists create queue.reading-queue
set queue.reading-queue.partitions 12
skip-if-exists attach machine.collector data_store.measurement-store
skip-if-exists create object_store.archive
set object_store.archive.class sparse
SON

The declarative definition is four lines, the step list nine, and both carry the same nine items. The real difference is not size: in the definition, attached is an attribute, and it says the machine is attached to the data store; in the list, attach is a step at the very end, and it says when the attaching happens. The first declares a dependency, the second declares an order.

// engine.mjs — environment model and two runners; both are models, no real infrastructure is provisioned.
import { readFileSync } from "node:fs";

export const newWorld = (quota) => ({ resource: new Set(), item: new Map(), quota,
  attempted: 0, applied: 0, noop: 0, error: 0, skipped: 0 });
export const copy = (d) => ({ ...d, resource: new Set(d.resource), item: new Map(d.item),
  attempted: 0, applied: 0, noop: 0, error: 0, skipped: 0 });

export const op = (d, name, target, value) => {           // create / set / attach
  d.attempted++;
  if (name === "create") {
    if (d.resource.has(target)) return { ok: false, reason: "already exists" };
    const kind = target.split(".")[0];
    const n = [...d.resource].filter((k) => k.startsWith(kind + ".")).length;
    if (n >= (d.quota[kind] ?? 9)) return { ok: false, reason: kind + " quota full" };
    d.resource.add(target); d.applied++; return { ok: true };
  }
  const key = name === "attach" ? target + ".attached" : target;
  const owner = key.split(".").slice(0, 2).join(".");
  if (!d.resource.has(owner)) return { ok: false, reason: "resource missing" };
  if (name === "attach" && !d.resource.has(value)) return { ok: false, reason: "target resource missing" };
  if (d.item.get(key) === value) { d.noop++; return { ok: true, noop: true }; }
  d.item.set(key, value); d.applied++; return { ok: true };
};

// Imperative: runs the step list in order. mode "stop" stops on the first error, "skip" skips the error.
export const imperative = (d, lines, mode) => {
  const log = [];
  for (const [i, raw] of lines.entries()) {
    const guarded = raw.startsWith("skip-if-exists ");
    const [name, target, value] = raw.replace("skip-if-exists ", "").split(" ");
    if (guarded && name === "create" && d.resource.has(target)) { d.skipped++; continue; }
    if (guarded && name === "attach" && d.item.get(target + ".attached") === value) { d.skipped++; continue; }
    const r = op(d, name, target, value);
    if (!r.ok) { d.error++; log.push(`step ${i + 1} (${name} ${target}): ${r.reason}`);
      if (mode === "stop") return { log, stoppedAt: i + 1, notAttempted: lines.length - i - 1 }; }
  }
  return { log, stoppedAt: 0, notAttempted: 0 };
};

const BLOCK = /^resource\s+(\w+)\s+"([\w-]+)"\s*\{(.*)\}$/;   // same as 01's configuration language
export const readTarget = (path) => {
  const k = new Map();
  for (const s of readFileSync(path, "utf8").split("\n")) {
    const m = BLOCK.exec(s.trim());
    if (!m) continue;
    k.set(`${m[1]}.${m[2]}`, "exists");                       // the resource itself is an item too
    for (const x of m[3].matchAll(/(\w+)\s*=\s*("[^"]*"|[\w./-]+)/g))
      k.set(`${m[1]}.${m[2]}.${x[1]}`, x[2].replaceAll('"', ""));
  }
  return k;
};

// Declarative: the diff is computed, an item whose dependency is unmet is skipped, order goes by depth.
export const declarative = (d, target) => {
  const log = [];
  const diff = [...target].filter(([k, v]) => (k.split(".").length === 2
    ? !d.resource.has(k) : d.item.get(k) !== v));
  for (const [k, v] of diff.sort((a, b) => a[0].split(".").length - b[0].split(".").length)) {
    const needs = [k.split(".").length > 2 ? k.split(".").slice(0, 2).join(".") : null,
      k.endsWith(".attached") ? v : null].filter(Boolean);
    if (needs.some((g) => !d.resource.has(g))) { d.skipped++; log.push(`${k}: dependency missing`); continue; }
    const [name, targetName] = k.split(".").length === 2 ? ["create", k]
      : k.endsWith(".attached") ? ["attach", k.split(".").slice(0, 2).join(".")] : ["set", k];
    const r = op(d, name, targetName, v);
    if (!r.ok) { d.error++; log.push(`${k}: ${r.reason}`); }
  }
  return { log, diff: diff.length };
};

export const report = (name, d, extra = "") =>
  `  ${name.padEnd(35)} attempted ${d.attempted}  applied ${d.applied}  no-op writes ${d.noop}` +
  `  errors ${d.error}  skipped ${d.skipped}${extra}`;

An Error at the Third Step

// scenario.mjs — the same target in two forms: partial failure, a rerun, a third run.
import { readFileSync } from "node:fs";
import { newWorld, copy, imperative, declarative, readTarget, report } from "./engine.mjs";

const lines = (p) => readFileSync(p, "utf8").trim().split("\n");
const steps = lines("steps.txt"), target = readTarget("target.infra");
const narrow = { data_store: 0 }, wide = { data_store: 1 };   // third step fails on the narrow quota

console.log(`target ${target.size} items   step list ${steps.length} lines\n`);
console.log("A. partial failure (data store quota 0)");
const bA = newWorld(narrow), sA = imperative(bA, steps, "stop");
console.log(report("imperative (stops on first error)", bA,
  `  not attempted ${sA.notAttempted}`));
console.log(`     ${sA.log.join("; ")}   record of where it stopped: 0 files`);
const dA = newWorld(narrow), tA = declarative(dA, target);
console.log(report("declarative (applies the diff)", dA, `  diff ${tA.diff}`));
console.log(`     ${tA.log.join("; ")}`);
console.log(`  items reaching the target   imperative ${bA.resource.size + bA.item.size}/${target.size}` +
  `   declarative ${dA.resource.size + dA.item.size}/${target.size}\n`);

console.log("B. second run (quota raised, recipe unchanged)");
const bB = copy({ ...bA, quota: wide }), sB = imperative(bB, steps, "stop");
console.log(report("imperative (same list from scratch)", bB, `  not attempted ${sB.notAttempted}`));
console.log(`     ${sB.log.join("; ")}`);
const shortened = steps.filter((a) => a !== "create network.measurement-network");   // 1 line removed by hand
const bB2 = copy({ ...bA, quota: wide });
imperative(bB2, shortened, "stop");
console.log(report("imperative (1 line removed)", bB2, `  hand edit 1 line`));
const dB = copy({ ...dA, quota: wide }), tB = declarative(dB, target);
console.log(report("declarative (same recipe)", dB, `  diff ${tB.diff}, hand edit 0 lines`));
console.log(`  items reaching the target   imperative ${bB2.resource.size + bB2.item.size}/${target.size}` +
  `   declarative ${dB.resource.size + dB.item.size}/${target.size}\n`);

console.log("C. third run (target reached, recipe rerun)");
const bC = copy({ ...bB2, quota: wide });
imperative(bC, steps, "skip");
console.log(report("imperative (skipping the error)", bC));
const dC = copy({ ...dB, quota: wide }), tC = declarative(dC, target);
console.log(report("declarative", dC, `  diff ${tC.diff}`));

console.log("\nD. target changed (collector 3 -> 5, queue partitions 6 -> 12, object store added)");
const target2 = readTarget("target-v2.infra"), steps2 = lines("steps-v2.txt");
const bD = copy({ ...bC, quota: wide });
imperative(bD, steps2, "skip");
console.log(report("imperative v2 (guarded list)", bD, `  list ${steps2.length} lines`));
const dD = copy({ ...dC, quota: wide }), tD = declarative(dD, target2);
console.log(report("declarative v2", dD, `  diff ${tD.diff}, definition ${target2.size} items`));
target 9 items   step list 9 lines

A. partial failure (data store quota 0)
  imperative (stops on first error)   attempted 3  applied 2  no-op writes 0  errors 1  skipped 0  not attempted 6
     step 3 (create data_store.measurement-store): data_store quota full   record of where it stopped: 0 files
  declarative (applies the diff)      attempted 7  applied 6  no-op writes 0  errors 1  skipped 2  diff 9
     data_store.measurement-store: data_store quota full; data_store.measurement-store.size_gb: dependency missing; machine.collector.attached: dependency missing
  items reaching the target   imperative 2/9   declarative 6/9

B. second run (quota raised, recipe unchanged)
  imperative (same list from scratch) attempted 1  applied 0  no-op writes 0  errors 1  skipped 0  not attempted 8
     step 1 (create network.measurement-network): already exists
  imperative (1 line removed)         attempted 8  applied 7  no-op writes 1  errors 0  skipped 0  hand edit 1 line
  declarative (same recipe)           attempted 3  applied 3  no-op writes 0  errors 0  skipped 0  diff 3, hand edit 0 lines
  items reaching the target   imperative 9/9   declarative 9/9

C. third run (target reached, recipe rerun)
  imperative (skipping the error)     attempted 9  applied 0  no-op writes 5  errors 4  skipped 0
  declarative                         attempted 0  applied 0  no-op writes 0  errors 0  skipped 0  diff 0

D. target changed (collector 3 -> 5, queue partitions 6 -> 12, object store added)
  imperative v2 (guarded list)        attempted 6  applied 4  no-op writes 2  errors 0  skipped 5  list 11 lines
  declarative v2                      attempted 4  applied 4  no-op writes 0  errors 0  skipped 0  diff 4, definition 11 items

The error at the third step produced a very different cost in the two forms. When the imperative list stopped, two items were in place and six steps had never been attempted — yet four of those six steps have nothing to do with the data store. Because the list declares an order, an error in the sequence stops everything after it too; the machine and the queue were left unopened even though the quota never touched them. The declarative applier hit the same error and put six items in place; it only skipped the two items that actually depend on the data store. The four-item difference between the two comes not from the applier’s speed but from what the recipe declares: if a dependency is declared, the error stops only its own subtree; if an order is declared, the error stops the whole rest of the queue.

The readability of the state left behind also differs. The only information the imperative run leaves behind is the step number it stopped at, and checking which items of the environment are in place takes a separate look; there is no record of where things were left off. The declarative run, on the other hand, recomputes the diff on the next run — the question “where were things left off” is never asked, because the answer is produced from scratch on every run.

Work Produced on a Rerun

The second run shows the same divide from a different angle. After the quota is raised, the imperative list, run again from the start, fails at the first step: the network already exists and create errors on an existing resource. Eight steps are never attempted. Advancing the list requires removing a line by hand; which line gets removed depends on the environment’s current state, and a different environment would need a different line removed. The declarative recipe, on the other hand, runs completely unedited, finds a diff of three items, and applies all three.

The third run counts idempotence directly. In an environment that has already reached the target, when the imperative list is run while skipping errors, four of the nine steps error and five produce a no-op write: the same value is written to the same item again, the environment does not change, but all nine operations are still attempted. The declarative recipe finds zero diff in the same environment and attempts no operation at all. The difference between the two is not an efficiency detail: four of the nine attempts show up as errors, and picking out the next real error from among these four takes separate work.

Lines Written When the Target Changes

#!/usr/bin/env bash
# Lines written when the target changes: the first and second versions of both recipes are compared.
printf 'recipe size (lines)   declarative %s -> %s   imperative %s -> %s\n' \
  "$(wc -l < target.infra | tr -d ' ')" "$(wc -l < target-v2.infra | tr -d ' ')" \
  "$(wc -l < steps.txt | tr -d ' ')" "$(wc -l < steps-v2.txt | tr -d ' ')"
printf 'lines written for the target change   declarative %s   imperative %s\n' \
  "$(diff target.infra target-v2.infra | grep -c '^>')" \
  "$(diff steps.txt steps-v2.txt | grep -c '^>')"
printf 'of these, %s lines add a guard, %s lines change a value, %s lines are a new resource\n' \
  "$(diff steps.txt steps-v2.txt | grep '^> skip-if-exists' | grep -vc archive)" \
  "$(diff steps.txt steps-v2.txt | grep '^> set' | grep -vc archive)" \
  "$(diff steps.txt steps-v2.txt | grep '^>' | grep -c archive)"
recipe size (lines)   declarative 4 -> 5   imperative 9 -> 11
lines written for the target change   declarative 3   imperative 9
of these, 5 lines add a guard, 2 lines change a value, 2 lines are a new resource

For the same target change, three lines were written in the declarative definition, nine in the step list. Of the nine, only four concern the target itself — two value changes and two lines for the new resource. The remaining five lines are a guard: “skip if exists” is written in front of every create step, or the list could not run a second time. These five lines do their job in section D: the run finishes with zero errors, five steps are skipped, four items are applied. The cost is two no-op writes — the guard reads whether the resource exists, not the value of the attribute.

What is visible here is a transformation. The way to make the imperative list rerunnable is to put a state read in front of every step; once these reads are complete, the list stops saying “what to do” and starts saying “what should be.” The boundary between the two forms dissolves at this point, and the only difference left is whether those reads are written into the recipe by hand or left to the applier. The gain of the declarative form is not a superiority, it is a non-repetition: the same read is not written nine times across nine lines, it is written once in the applier.

Summary

  • After the error at the third step, the items reaching the target were 2/9 in the imperative form and 6/9 in the declarative form; 5 of the 6 steps never attempted in the imperative run had nothing to do with the error.
  • The imperative run left behind no record beyond the step number; the declarative run recomputes the diff from scratch on the next run, so it never asks “where were things left off.”
  • On the second run the imperative list failed at step 1, and advancing it required removing 1 line by hand; the declarative recipe ran with 0 lines edited and applied 3 items.
  • In an environment that had already reached the target, the imperative list attempted 9 operations (4 errors, 5 no-op writes); the declarative recipe attempted 0 operations.
  • When the target changed, 3 lines were written in the declarative definition, 9 in the step list; 5 of the 9 were only guard lines making the list rerunnable.

Next Step

Up to this point, both recipes did the same thing: they changed an existing resource in place. When the queue’s partition count went from six to twelve, it was still the same queue; when the machine count went from three to five, the existing three machines were untouched. This is not the only way to change infrastructure.

The next lesson makes the same update two ways — an in-place patch and a rebuild — and counts four things: how many machines diverge from one another by the end of a period, the update duration, the rollback round count, and what a rebuild cannot carry across. The case where a rebuild gains nothing is shown with the same measure too.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close