Skip to main content
Learning Center
Workflow Automation

Capstone: The Order Digest

Capstone Part One: Assembling the Digest

Joining collection, validation, aggregation and reporting into one workflow with a single entry point, where every stage hands the next one a value rather than a side effect.

Lesson 16 of 18 in the recommended order · About 30 min (estimate)

On this page
Workflow automation glossary — terms and common confusions
Dry run
A run that reports every change it would make and makes none of them.

A dry run that still writes a log file, sends a message, or creates a folder is not a dry run.

Idempotent
Running it again leaves the same result as running it once.

"It did not crash the second time" is not the same property; check what the second run changed.

Run record
The stored facts about one execution: when it started, what it read, what it changed, how it ended.

A run record is not the log. The log is prose for a person; the record is data the next run reads.

Transient failure
A failure that a later identical attempt could succeed at, such as a timeout.

A rejected record and a dropped connection are not the same failure, and retrying the first one forever is a bug.

Backoff
Waiting longer between successive retries instead of retrying immediately.

Backoff without a maximum attempt count is an unbounded loop with a politeness delay.

Request budget
A hard cap on how many requests one run may make, checked before each request.

A page limit is not a budget if a retry can make extra requests the limit never counts.

Quarantine
Setting aside a record a run could not process, with the reason, so the run continues.

Quarantine is not "skip". A skipped record leaves no trace; a quarantined one is countable and reviewable.

Reconciliation
Showing that the counts in a report add up: read equals processed plus quarantined plus rejected.

A report whose totals cannot be reconciled is a summary of what the code believed, not of what happened.

Trigger
The event or time that causes a run to start.

Scheduling a trigger is a decision made on a machine, not something a browser lesson can install for you.

Heartbeat
A signal a healthy run emits, whose absence is itself the alert.

Alerting only on errors cannot detect the job that stopped running at all, which is the most common outage.

Staleness
How old the most recent successful run is, compared with how old it is allowed to be.

A green last run is not freshness. Ask when it ran, not whether it passed.

Atomic replace
Writing output to a temporary name and renaming it into place in one step.

A reader never sees a half-written file; an interrupted run leaves the previous output intact.

Transport
The injected callable that actually performs a request, separate from the client that interprets it.

A client that builds its own connection cannot be tested without a network, which is why the seam exists.

In-memory filesystem
The filesystem this course’s browser exercises operate on, which lives only inside the tab.

It behaves like a filesystem and is not your disk: nothing an exercise writes exists after the run ends.

Outcome

By the end of this lesson you have the shape of the whole project: one entry point, five stages that each take a value and return a value, and a digest whose totals can be checked against the number of records that went in.

Why it matters

Five techniques that each work are not a workflow. The thing that makes them one is the seam between them, and the seam is where projects like this usually come apart.

The failure mode has a recognisable shape: stage two writes into a dictionary that stage four also writes into, stage three reads a global that stage one set, and by the time somebody wants to test stage four alone they cannot, because stage four does not take an input — it takes the accumulated state of everything before it. At that point the only way to test anything is to run all of it, and the only way to find a defect is to read all of it.

The alternative costs nothing at the time and is the difference between a project you can change and one you can only rewrite: every stage takes a value and returns a value. Collection returns files. Parsing returns records and rejections. Validation returns accepted and quarantined. Aggregation returns totals. Reporting returns lines. Each one can be run on its own, with hand-made input, in a test that takes a millisecond.

The digest also needs to be checkable. A report saying "north: 2 orders, 21 units" is a claim, and the only way anybody can believe it is if the same run also says how many records it read, how many it accepted, and how many it set aside — and if those numbers add up.

Concept

The layout. A project somebody else can read has an obvious front door:

order-digest/
  run.py              the entry point; parses arguments, calls the pipeline, exits with a status
  digest/
    collect.py        inbox -> list of files
    parse.py          rows -> records, rejections
    validate.py       records -> accepted, quarantined
    aggregate.py      accepted -> totals
    report.py         totals + counts -> report lines
    record.py         everything -> the run record
  settings.toml       paths, budget, deadline, schedule interval
  fixtures/           the recorded inputs the tests run against
  tests/
  RUNBOOK.md

Nothing here is required by Python; it is required by the person reading it in a year. The one rule worth insisting on is that run.py contains no domain transformations: it reads settings, parses arguments, calls stages, and decides the exit status. Keep parsing, validation, and aggregation in functions that can be tested without running the whole program.

If you would rather start from something running, starter_daily_digest.py is this shape collapsed into one file, with four functions left for you and a completed version to compare against afterwards. It reads sample-orders.csv, which is the same data as the capstone's first fixture, so the totals it produces are the ones the capstone's expected output states.

The pipeline, as one function. The five processing stages are collect, parse, validate, aggregate, and report. The run record is a separate final output.

Trace the digest stage boundaries

Five processing stages, with named handoffs

Reconcile the conceptual five-stage run: read = rejected + quarantined + accepted

Each read row must end in exactly one bucket. The smaller practice exercise combines all set-aside rows into quarantined instead.

1. Collect

Receives
Inbox and archive locations
Hands forward
files: the selected input files

File selection and archiving are boundary effects; the next stage receives a value.

Next step in this same run

2. Parse

Receives
rows from files, plus schema
Hands forward
records: parsed candidates and rejected rows

A rejection carries a reason and contributes to the read count.

Next step in this same run

3. Validate

Receives
parsed candidates, rules and lookups
Hands forward
checked: accepted and quarantined records

Only accepted records may enter aggregation.

Next step in this same run

4. Aggregate

Receives
checked.accepted
Hands forward
totals: grouped orders, units and value

This stage should not need input files or shared mutable totals.

Next step in this same run

5. Report

Receives
totals, reconciled counts and metadata
Hands forward
lines: the human-readable digest

A separate final run record holds counts, duration and status; run.py orchestrates the calls and exit status.

Architecture map, not completed code. In the worked and practice examples below, parse_rows performs some validation itself; their smaller reconciliation is read = accepted + rejected or read = accepted + quarantined, respectively. The diagram gives no exercise output or implementation.
files      = collect(inbox, archive)
records    = parse(rows_from(files), schema)
checked    = validate(records.accepted, rules, lookups)
totals     = aggregate(checked.accepted)
counts     = counts_from(records, checked)
lines      = report(totals, counts, metadata)
record     = finish(job, counts, seconds, status)

This is a map of value handoffs, not executable code or a prescribed function API: rows_from, counts_from, and finish stand for work the entry point must arrange. Keep the transformation stages independent of shared state. Collection may archive input, while writing report lines or a run record belongs at an explicit output boundary.

Counts flow forward, and they are the reconciliation. Each stage reports what it received and what it produced, and the final record adds them up:

read        rows that came out of the files
rejected    rows parse could not turn into records
quarantined records validate set aside
accepted    records that reached the aggregation
read == rejected + quarantined + accepted

That equation is the thing to check in code. A digest that cannot state it is a digest nobody can check.

Settings, in one place. The inbox path, the archive path, the request budget, the deadline, the schedule interval, and the region lookup all belong in one file that is read once, at the start, by the entry point. Values scattered through the stages are values nobody can find when the job needs to move to another machine.

One exit status. 0 for a completed run, and something non-zero for anything else, so a scheduler can tell. A refused run is not a crash, and giving it its own status — 2, say — lets monitoring distinguish "did not run because the input was not there" from "broke".

Read the code

The middle of the pipeline, joined up:

def parse_rows(rows, regions):
    """Rows to typed records, or a rejection naming the field."""
    accepted, rejected = [], []
    for row in rows:
        try:
            units = int(row["units"])
        except (KeyError, ValueError):
            rejected.append((row.get("order_id", "?"), f"units: {row.get('units', '')!r} is not a whole number"))
            continue
        if row["region_code"] not in regions:
            rejected.append((row["order_id"], f"region_code: {row['region_code']} is not a known region"))
            continue
        accepted.append({**row, "units": units, "group": regions[row["region_code"]]})
    return accepted, rejected


def aggregate(records):
    """Totals by reporting group, from accepted records only."""
    totals = {}
    for record in records:
        group = totals.setdefault(record["group"], {"orders": 0, "units": 0, "value": 0.0})
        group["orders"] += 1
        group["units"] += record["units"]
        group["value"] += record["units"] * float(record["unit_price"])
    return totals


REGIONS = {"NW": "north", "NE": "north", "SW": "south", "SE": "south"}
ROWS = [
    {"order_id": "ORD-1", "region_code": "NW", "units": "12", "unit_price": "4.50"},
    {"order_id": "ORD-2", "region_code": "ZZ", "units": "3", "unit_price": "19.99"},
    {"order_id": "ORD-3", "region_code": "NE", "units": "two", "unit_price": "7.25"},
]

accepted, rejected = parse_rows(ROWS, REGIONS)
totals = aggregate(accepted)
print("read:", len(ROWS))
print("accepted:", len(accepted))
print("rejected:", len(rejected))
for group in sorted(totals):
    figures = totals[group]
    print(f"{group}: {figures['orders']} orders, {figures['units']} units, {figures['value']:.2f}")
for order_id, reason in rejected:
    print("rejected:", order_id, reason)

parse_rows returns two lists for the shown bad-units and unknown-region cases, so those rows do not stop this batch. Returning them lets the caller count both outcomes. This small example does not handle every malformed row: missing region_code or order_id can still raise, and aggregate can raise on an invalid unit_price. A production input contract would need to handle those cases deliberately.

aggregate takes only the accepted records. It never sees a rejection, so it cannot accidentally include one, and it can be tested with three hand-written dictionaries.

{**row, "units": units, ...} builds a new record rather than modifying the row in place. The caller still holds the original, unmodified, which matters when a later stage wants to report what actually arrived.

:.2f formats the displayed money value to two decimal places. Floating-point arithmetic can sometimes produce longer raw decimal representations; this formatting keeps the report readable. For money that must be exact across many operations, use decimal arithmetic rather than relying on display formatting.

Predict the output

Predict every line, in order.

Check your prediction
read: 3
accepted: 1
rejected: 2
north: 1 orders, 12 units, 54.00
rejected: ORD-2 region_code: ZZ is not a known region
rejected: ORD-3 units: 'two' is not a whole number

Only ORD-1 survives. ORD-2 carries a region code the lookup does not contain and ORD-3 has units that will not convert, and each is rejected with a reason naming the field and the value.

1 orders reads badly and is deliberate here: pluralising in a format string is a decision worth making once, in the reporting stage, rather than in the middle of an aggregation.

The counts reconcile: 3 read, 1 accepted, 2 rejected.

Modify the code

Make aggregate take all the rows instead of only the accepted ones, by calling aggregate(ROWS).

What changes, and why

It raises:

KeyError: 'group'

The raw rows never went through parse_rows, so none of them has a group key. The crash is the good outcome, and it is worth understanding why it was available: aggregate requires a field that only the parsing stage adds, so a caller that skips the stage is stopped immediately rather than producing totals from unvalidated data.

If aggregate used record.get("group", "unknown"), it would hide the missing group at first and create an unknown bucket. It would then increment that bucket's order count for ORD-1 before raising when it tries to add the raw string "12" to its numeric units total. The code would not complete a valid digest; the fallback only defers the failure and leaves a partially changed accumulator. Accommodating input a stage was not designed for weakens the validation boundary.

Debug the bug

An assistant was asked to "put the pipeline together into one script". It produced this.

TOTALS = {}
ERRORS = []

def process(rows):
    for row in rows:
        try:
            TOTALS.setdefault(row["region_code"], 0)
            TOTALS[row["region_code"]] += int(row["units"])
        except Exception as error:
            ERRORS.append(str(error))

def main():
    process(read_rows())
    print(f"Digest: {TOTALS}")
    if ERRORS:
        print(f"{len(ERRORS)} errors")
What's actually wrong
  1. The stages share module-level state. TOTALS and ERRORS are globals, so nothing can be tested in isolation, two runs in one process contaminate each other, and the only way to know what process did is to inspect variables it did not return.
  2. Nothing reconciles. The number of rows read is never counted, so TOTALS and ERRORS cannot be checked against it. When setdefault succeeds before int fails, a zero-valued region key remains and an error is recorded, but the output does not say which input row contributed or failed.
  3. except Exception around the whole body catches a KeyError for a missing column and a ValueError for bad units identically, and records only the exception's text. That text may contain a field name or bad value, but it does not reliably identify the order or preserve structured context.
  4. It groups by raw region code, never consulting the lookup, so an unknown code becomes a legitimate-looking group in the digest.
  5. It reports units only. No order count, no value, and no way to tell whether a group with 40 units is one large order or forty small ones.
  6. print(f"Digest: {TOTALS}") puts a Python dictionary in front of a person at eight in the morning.

The version that can be tested a stage at a time:

def process(rows, regions):
    accepted, rejected = parse_rows(rows, regions)
    return {
        "totals": aggregate(accepted),
        "counts": {"read": len(rows), "accepted": len(accepted), "rejected": len(rejected)},
        "rejected": rejected,
    }

One function, no globals, everything returned. Each stage inside it takes a value and gives one back, the counts come out with the totals so they can be reconciled, and every rejection carries the identifier and reason a person needs.

Try it yourself

Assemble the digest. Write parse_rows, aggregate, and report, and run them over the supplied orders and region lookup. The printed digest must reconcile: read equals accepted plus quarantined.

Loading this exercise…

Practical challenge (optional)

Optional, and the transfer task for this lesson: make the digest comparable with yesterday's.

Add a second supplied day of orders, produce both digests, and write the three lines that describe the difference between them — orders, units, and value, per group, as a change rather than a level. Then decide what the report should say on the first day, when there is nothing to compare against.

What a good answer looks like

The first-day case is the interesting one, and "0% change" is the wrong answer: it claims a comparison that did not happen. no previous run to compare against is the honest line, and it is the same distinction the staleness check drew between "stale" and "never succeeded".

The second thing people find is that a comparison needs the groups from both days, not just today's. A region that had orders yesterday and none today disappears from today's totals entirely, so a difference computed by walking today's groups reports nothing about it — which is exactly the change most worth noticing.

Sign in to track your progress on this exercise.

AI collaboration

Checkpoint

  1. Why should every stage take a value and return a value rather than update shared state?
  2. What does the reconciliation equation compare, and where should it be checked?
  3. Why should run.py keep domain transformations out of the entry point?
  4. Why is it better for aggregate to raise on an unparsed row than to accommodate it?
Answers
  1. Because a stage that reads shared state cannot be run on its own: it needs everything before it to have run first. Passing values means each stage can be tested in a millisecond with hand-written input, and two runs in one process cannot contaminate each other.
  2. That the number of rows read equals rejected plus quarantined plus accepted, checked in code at the end of the run, and reported in the digest so a reader can see the numbers account for each other.
  3. Because parsing, validation, and aggregation in the entry point are harder to test on their own. Argument parsing, orchestration, and exit-status selection still belong there.
  4. Because accommodating input a stage was not designed for can hide a missing transformation. A missing group key means the row skipped parsing or normalization; raising shows that boundary failure immediately, while a fallback could defer it.

Sign in to track your progress on this exercise.

Summary and next step

One entry point without domain transformations, stages that each take a value and return a value, counts that flow forward and reconcile at the end, settings in one file, and one exit status a scheduler can read. That is the whole digest, and it works on a good day. The next lesson is about the other days: interrupting it deliberately, proving the re-run redoes nothing it already did, and checking that a second run over unchanged input changes nothing at all.

learning.goultergroup.com

The interactive parts of this page have not loaded. Reading and links still work; reload the page to try again.