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.