1. Courses
  2. Data
  3. Machine Learning in Production
  4. Stop 1

35 minAdvanced

From notebook to pipeline

Tomás's demand forecast beats the baseline, but it lives in a notebook that one person runs by hand. This lesson turns it into a pipeline: small steps with clear inputs and outputs, safe to re-run, scheduled as a DAG with retries and a deadline, packaged in Docker and tested before it ships. Then 320 stores get a forecast every night, whoever is on leave.

By the end you can

  • Name the five things that make a notebook unsafe to run every night
  • Split a model's nightly work into steps with a contract: inputs, one output partition and a run date
  • Make every step idempotent, so a retry or a backfill overwrites instead of doubling
  • Schedule the steps as a DAG with retries, timeouts, a deadline and alerts, and say when cron is enough
  • Package the pipeline in a Docker image with pinned versions, and test it in CI on a small sample

The everyday picture

Think of a neighbourhood bakery. For years the owner baked every morning from memory: a pinch of this, the oven "hot enough", the dough left until it looked right. The bread was good. Then she fell ill in the busiest week of the year, nobody else could bake it, and the shop opened at 7:00 with empty shelves.

A bakery that supplies fifty shops works differently. Each recipe is written on a card with exact weights and oven temperatures. The work is split into stations (mixing, proving, baking, packing), and each station takes a known input and hands on a known output. A board on the wall says when each station must start so the vans leave at 6:00. When a tray burns, the baker bakes that tray again and bins the burnt one; nobody puts both on the shelf. And loaves reach the van only after someone checks them. A production pipeline is that bakery: written recipes, stations with inputs and outputs, a timetable with a deadline, redo instead of add, and a check before anything leaves.

Why the notebook breaks at night

In Forecasting demand Tomás built a gradient boosting forecast that beat the 28-day baseline on the backtest: WAPE 24% against 34%, and 35% against 58% in the festival week. In the 20-store pilot he ran it himself: at about 22:00 he ran the notebook top to bottom (mostly) and copied a CSV file into a shared folder that the ordering system read.

Production is a different job. About 320 stores each range roughly 8,000 of the 25,000 products, so there are about 2.5 million active store-product pairs (approximate). Forecasting 4 days ahead means about 10 million predictions a night, ready before the distribution centres (DCs) close their order intake. A notebook run by a person fails that job in five predictable ways:

  • . A DataFrame from a cell Tomás has since deleted is still in memory, so the notebook works today and fails on a fresh start.
  • Out-of-order cells. The result depends on the order cells were clicked. Cell 14 ran before cell 9 last Tuesday, and nobody wrote that down.
  • Manual steps. Download the extract, rename it, copy the output to the shared folder. Each step lives only in one person's habits.
  • Hard-coded dates and paths. TODAY = "2026-03-14" and C:/Users/tomas/sales.csv. Forget to edit the date and the system orders tomorrow's stock from yesterday's forecast with today's label.
  • Unpinned packages. Whatever pandas and scikit-learn are installed today. scikit-learn warns when you load a model saved with a different version, and does not promise it behaves the same.

Google's MLOps guide calls this stage "level 0": every step manual, run by the data scientist. Sculley and colleagues ("Hidden Technical Debt in Machine Learning Systems") point out that the model code is a small part of a real ML system; the data handling, checks, configuration and monitoring around it hold most of the work. Notebooks stay excellent for exploring. The job is to move the logic into code that runs the same way every night without a person.

Quick check

Tomás's notebook gives the right forecast on his laptop, but "Restart and run all" fails at cell 12 with "name promo_flags is not defined". What is the problem?

Steps with a contract

The first move is to split the notebook into a of steps. For the nightly forecast there are six, plus one that runs weekly:

StepReads (for run date D)WritesTypical time
extractdaily_sales, stock_snapshot, promo, festival up to Draw partition for D10 min
validateraw partition for Da pass or fail report5 min
build featuresraw partition for Dstore_article_features rows with as_of_date = D20 min
predictfeatures for D, the champion modelforecast rows with this night's run_id40 min
evaluatetonight's forecast; yesterday's forecast and saleschecks and WAPE on forecast_run10 min
publisha run that passed evaluateforecast_run.status = publishedunder 5 min
train (Sundays)features for the last two yearsa candidate model versionabout 2 hours

Validate is a gate that checks the data before the model sees it (Data and feature pipelines builds those checks). Evaluate checks tonight's output (one row per store, product and day, no negative forecasts, a chain total close to last week's) and scores yesterday's forecast against actual sales. Train only registers a candidate; the nightly predict always uses the current , and Experiments, versions and the model registry covers how a candidate is promoted.

Each step gets a written contract with four parts:

  • Inputs: named tables or , for one run date and nothing else.
  • Output: exactly one partition, or rows tagged with one run_id. A step never edits other steps' outputs.
  • Parameter: the run date, passed in as --run-date 2026-10-20. The step never asks the clock what day it is.
  • Exit code: 0 for success, anything else for failure. A step that hits a problem fails loudly; it never writes an empty file and exits 0.

The run date parameter is what makes a possible. Suppose the promo feature was wrong from 1 to 7 October. You fix the code and run build features, predict and evaluate for each of those seven dates, with the same code that runs tonight.

Small steps also make failures cheaper: if predict fails, the retry starts at predict, and the extract is already in its partition. Put a boundary where the resources change (predict needs far more CPU), at a natural checkpoint (validated data) or where the schedule differs (weekly training). Don't wrap every function in a step: each boundary costs a write and a read.

warehouse 23:15 extract 23:25 validate 23:30 build features 23:50 predict 00:30 evaluate 00:40 publish train Sundays only candidate model registry champion model alert on-call if checks fail DC orders Store app 01:30 publish deadline 02:00 DC order cut-off
The nightly DAG: each step starts when the one before it succeeds, every step has retries and a timeout, and the whole run fits a 90-minute budget (23:15 to about 00:45). Predict reads the champion from the registry; the weekly train step only adds candidates. If evaluate's checks fail, nothing is published and the on-call engineer is alerted.

Idempotent steps: re-run without fear

Retries are normal in a nightly pipeline. A warehouse connection drops, a machine is reclaimed, a disk fills. The scheduler will run some steps twice, sometimes after the first attempt has already written part of its output. So every step must be : running it twice for the same run date leaves the same result as running it once. Three rules get you there.

  1. Write to a place named by the run. Features go to the partition for as_of_date = 2026-10-20; forecast rows carry run_id = 'fc-2026-10-20'. A retry writes to the same place as the attempt that failed.
  2. Overwrite on re-run. For files, write to a temporary name and rename it over the old file. For tables, delete the run's rows and insert the new ones in one transaction, or replace the whole partition. A half-written attempt is wiped by the next one.
  3. Publish last. Readers (the DC ordering system, the Store app's ordering screen) never read a partition while it is being written. They read the newest run marked published in forecast_run, and publish is the last, tiny step that sets that status; until then everyone keeps last night's forecast. Serving: batch and online builds this read and its fallback.

The opposite is "append and hope". Suppose predict crashes at 00:02 after inserting 1.2 million of tonight's 10 million rows, and the retry inserts all 10 million again. Now 1.2 million store-product-days appear twice, and anything that sums forecasts per store will order those products double. Nothing errors, so nobody notices until shelves overflow.

Two traps look idempotent and aren't. "Skip if the partition exists" turns a half-written partition into a permanent one, so check for a completion marker instead. And side effects outside your storage, such as sending the order file to a DC, also run twice on a retry. Give them a key built from the run_id so the receiver can ignore a repeat, the idempotency key from Queues, background jobs and pub/sub.

tonight's partition what readers use 23:50 run 1 1.2M of 10M rows crashed at 00:02 run for 10-19 last night retry overwrites 00:07 retry 10M rows same partition run for 10-19 unchanged checks pass 00:40 publish 10M rows status published run for 10-20 switched last orders read this DC orders Store app with append instead: 11.2M rows, 1.2M doubled
An idempotent step writes to the place named by its run date, so the retry replaces the half-written attempt instead of adding to it. Readers move to tonight's run only when publish marks it complete; until then they keep last night's. Appending would have left 1.2 million rows counted twice, with no error.

Quick check

The extract step appends tonight's sales to a raw_sales table. It failed after loading 60% of the rows, and the scheduler retried it successfully. What do you expect tomorrow?

Orchestration: a DAG with a deadline

An runs the steps. You describe them as a , a graph of steps where each arrow says "this step needs that one" and there are no loops, and the orchestrator does the rest:

  • Schedule. The nightly DAG starts at 23:15, after the POS sales sync to the warehouse by about 23:00; training runs on Sunday evening. About 2% of a day's transactions arrive after midnight from offline tills (approximate), and the next night's extract picks them up (ml-02).
  • Dependencies. A step starts only when every step it needs has succeeded. If validate fails, nothing downstream runs, and readers keep last night's forecast.
  • Retries with backoff. Two retries per step with , 5 minutes and then 10. Never unlimited: a third failure is a real problem.
  • Timeouts. Predict normally takes 40 minutes, so it gets a 50-minute timeout. A step that hangs without an error is the worst kind of failure, because it eats the slack in silence.
  • A deadline and alerts. The is "published by 01:30". If a step fails after its retries, or 01:30 passes with nothing published, the on-call engineer is paged with a link to the runbook. Monitoring, drift and retraining covers which alerts deserve a page.
  • Backfills and history. Run the DAG for a range of past dates with one command, and see every run, attempt and log afterwards (Observability: logs, metrics and traces).

Do the time arithmetic before the first night. The run's budget is 90 minutes, 23:15 to about 00:45. That leaves 45 minutes before the 01:30 deadline and 75 before the DCs' 02:00 cut-off, roughly an hour of slack for one retry. A step that fails fast fits easily: extract losing its connection at 23:17 costs about 7 minutes. A worst case does not fit. If predict hangs until its 50-minute timeout at 00:40, the retry starts at 00:45 and predict finishes around 01:25; evaluate and publish land near 01:40, after the deadline. Plan that night in advance: the deadline alert fires at 01:30, the ordering system falls back to last night's forecast shifted by a day, and a person decides whether to wait. Splitting predict across four workers (ml-04) shrinks the worst case.

Do you need an orchestrator at all? For one script with no dependencies, cron is enough: it runs a command at a set time, and a small wrapper sends an alert when the command exits with an error. Once you have several steps with dependencies, per-step retries, a deadline and backfills, building that around cron means rebuilding an orchestrator, badly. Apache Airflow, Dagster and Prefect are common open-source choices, with managed equivalents in each cloud; the ideas above apply to all of them. Keep the business logic in the step code and only "when" and "in what order" in the DAG, so any step can also be run by hand with the same arguments.

Quick check

Predict usually takes 40 minutes. One night it hangs without an error. Why does a 50-minute timeout on that step help?

One environment, tested before it ships

The steps run inside one per pipeline. Every step, including weekly training, uses the same image, so the feature code that trains the model is the code that predicts with it. Each run records the image tag (for example forecast-pipeline:1.4.2), so you can say which code made any night's forecast; the registry lesson adds the image digest and data snapshot. Shipping safely: containers, CI/CD and releases covers images in general.

Pin everything that could change under you. A lists exact versions of every package, including the ones your packages pull in; tools such as pip-tools or uv generate it from the short list you asked for. Pin the base image too (python:3.12-slim, or its digest). The test: rebuild next month from the same commit and get the same packages.

Keep three kinds of setting apart. Code goes in the image. Config that changes between environments (the warehouse host, the bucket, "all stores" or "5 sample stores") comes from a config file or environment variables. Secrets, such as the warehouse password, are injected at run time from a secret store, never put in the image or in Git: anyone who can pull an image can read every file in it.

Then test at three levels:

  • Unit tests for the feature functions. Keep them pure (a DataFrame in, a DataFrame out, no reading or writing), so a test can feed in five rows. The leak checks from Features and leakage become permanent tests.
  • An end-to-end run on a sample in . Two weeks of data for 5 stores and 200 products sits in the repository; on every pull request, GitHub Actions runs every step on it in a few minutes, checks the row counts, then runs it again and checks that nothing changed. That second run is your idempotency test.
  • Data checks at run time. The code can be right while tonight's data is wrong. That is the validate step, built in the next lesson.
Python
# tests/unit/test_features.py
from datetime import date

import pandas as pd

from pipeline.features import add_lag_features


def test_lag_uses_only_sales_up_to_the_run_date():
    sales = pd.DataFrame({
        "store_id": [14, 14, 14],
        "article_id": [501, 501, 501],
        "sale_date": pd.to_datetime(["2026-10-18", "2026-10-19", "2026-10-20"]),
        "units": [5, 7, 99],
    })
    out = add_lag_features(sales, as_of=date(2026, 10, 19))
    # the 99 units sold on the 20th must not leak into features as of the 19th
    assert out.loc[0, "units_last_7d"] == 12

Build it

Turn the forecast notebook into a pipeline that runs in Docker, is scheduled as a DAG and is tested on every pull request.

  1. Lay out the project

    One repository for the pipeline. Feature logic lives in pure functions, reading and writing partitions in one module, and each step in its own small script.

    Output
    forecast-pipeline/
      Dockerfile
      requirements.in          # what we ask for: pandas, scikit-learn, pyarrow
      requirements.lock        # what we get: every package, exact versions
      config/
        prod.yaml              # warehouse schema, bucket, all stores
        sample.yaml            # 5 stores, local folder, for CI
      dags/
        nightly_forecast.yaml  # when and in what order, nothing else
      pipeline/
        features.py            # pure functions: DataFrame in, DataFrame out
        io.py                  # the one place that reads and writes partitions
        run_all.py             # runs steps in order, for CI and backfills
        steps/
          extract.py  validate.py  build_features.py
          predict.py  evaluate.py  publish.py
          train.py             # weekly
      tests/
        unit/test_features.py
        sample/                # 2 weeks of data, 5 stores x 200 products
  2. Write each step as a script with a run date

    The step takes --run-date, reads only that date's input, writes only that date's partition, and replaces the file atomically. It raises an error instead of writing nothing.

    Python
    # pipeline/steps/build_features.py
    import argparse
    import os
    from datetime import date
    from pathlib import Path
    
    import pandas as pd
    
    from pipeline.features import add_lag_features  # pure, unit-tested
    
    DATA_ROOT = Path(os.environ.get("DATA_ROOT", "data"))  # config, not code
    
    
    def run(run_date: date) -> Path:
        src = DATA_ROOT / "raw" / f"run_date={run_date}" / "sales.parquet"
        out_dir = DATA_ROOT / "features" / f"as_of_date={run_date}"
        out_dir.mkdir(parents=True, exist_ok=True)
    
        sales = pd.read_parquet(src)  # input: this run date's extract only
        features = add_lag_features(sales, as_of=run_date)
        if features.empty:
            raise ValueError(f"no feature rows for {run_date}")  # fail loudly
    
        tmp = out_dir / "part.parquet.tmp"
        features.to_parquet(tmp, index=False)
        os.replace(tmp, out_dir / "part.parquet")  # atomic swap on one disk
        return out_dir
    
    
    if __name__ == "__main__":
        parser = argparse.ArgumentParser()
        parser.add_argument("--run-date", required=True, type=date.fromisoformat)
        args = parser.parse_args()
        print(run(args.run_date))

    On object storage, one uploaded object replaces the old one whole, so the same rule holds. For a partition made of many files, write them under a new prefix and publish a pointer to it last.

  3. Pin the environment in one image

    Install from the lock file first so Docker caches that layer, then copy the code. No secrets in the image.

    Dockerfile
    # One image for every step of the forecast pipeline
    FROM python:3.12-slim
    
    WORKDIR /app
    
    # Locked versions first: this layer is rebuilt only when the lock changes
    COPY requirements.lock .
    RUN pip install --no-cache-dir -r requirements.lock
    
    COPY config/ config/
    COPY pipeline/ pipeline/
    
    # The warehouse password arrives as an environment variable at run time
    ENV PYTHONUNBUFFERED=1
    ENTRYPOINT ["python", "-m"]
    Output
    # requirements.lock (excerpt): every package, exact version
    numpy==2.1.3
    pandas==2.2.3
    pyarrow==17.0.0
    scikit-learn==1.5.2
    scipy==1.14.1
    joblib==1.4.2
    Terminal
    docker build -t forecast-pipeline:1.4.2 .
    docker run --rm -e DATA_ROOT=/data -v /mnt/forecast:/data forecast-pipeline:1.4.2 pipeline.steps.build_features --run-date 2026-10-20
  4. Describe the DAG

    This is a generic sketch, not any one tool's format. Every orchestrator has these settings under its own names; in Airflow, for example, retries, retry_delay and execution_timeout are task arguments.

    YAML
    # dags/nightly_forecast.yaml: a generic sketch
    pipeline: nightly-forecast
    image: forecast-pipeline:1.4.2
    schedule: "15 23 * * *"        # 23:15 every night
    timezone: store-local          # set it explicitly, never trust the server clock
    params:
      run_date: logical-date       # the business date, passed to every step
    defaults:
      retries: 2
      retry_delay: 5m
      retry_backoff: exponential   # 5 minutes, then 10
      timeout: 30m
    deadline:
      publish_by: "01:30"
      on_miss: page ml-oncall      # with a link to the runbook
    steps:
      extract:        { run: pipeline.steps.extract,        needs: [] }
      validate:       { run: pipeline.steps.validate,       needs: [extract] }
      build_features: { run: pipeline.steps.build_features, needs: [validate] }
      predict:        { run: pipeline.steps.predict,        needs: [build_features], timeout: 50m }
      evaluate:       { run: pipeline.steps.evaluate,       needs: [predict] }
      publish:        { run: pipeline.steps.publish,        needs: [evaluate] }
    # a second DAG, weekly-train, runs pipeline.steps.train on Sundays at 19:00
  5. Test on every pull request

    Unit tests, then the whole pipeline on the sample, twice, and a check that the second run changed nothing. Last, prove the image still builds.

    YAML
    # .github/workflows/pipeline-tests.yml
    name: pipeline-tests
    on: [pull_request]
    jobs:
      test:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
          - uses: actions/setup-python@v5
            with:
              python-version: "3.12"
          - run: pip install -r requirements.lock pytest==8.3.3
          - name: Unit tests for the feature functions
            run: pytest tests/unit
          - name: End-to-end on the 5-store sample, run twice
            env:
              DATA_ROOT: tests/sample
            run: |
              python -m pipeline.run_all --run-date 2026-03-10 --from validate
              cp -r tests/sample/forecast first_run
              python -m pipeline.run_all --run-date 2026-03-10 --from validate
              python -m tests.compare_partitions first_run tests/sample/forecast
          - name: The image builds
            run: docker build -t forecast-pipeline:ci .
  6. Shadow, then switch

    Run the pipeline next to the old process for a week. Compare the outputs every morning, row counts and totals per store first. Switch the readers only when they match and every night finished on time, and keep the old process ready for one more week.

Bring it to your team

Ask these in the review before a model's first night in production.

  • If this step runs twice for the same date, what changes? (The right answer is "nothing".)
  • Where does each step get its date from, and what happens on a retry after midnight?
  • What happens at 01:30 if nothing is published, and who is told?
  • If we rebuild this image next month from the same commit, do we get the same packages?
  • Where do the warehouse credentials live, and who can read them?
  • Can someone other than the model's author run, re-run and backfill this from the runbook?

Red flags

  • "It ran fine on my machine" as the test plan.
  • An output table that steps append to, with no run date or run id.
  • date.today() or now() inside step code.
  • A requirements file with no versions, or FROM python:latest.
  • Unlimited retries, or no timeouts.
  • Alerts that go to a channel nobody owns at 01:30.

Practice

Order the steps, and find what to re-run

Write two functions. runOrder(steps) takes an object where each key is a step name and each value is the list of steps it needs. Return every step name in an order where each step comes after everything it needs. When several steps are ready at the same time, take the alphabetically first one, so the answer is unique. If the steps can't all run (a cycle, such as a needs c, c needs b, b needs a), throw an Error. stepsToRerun(steps, failed) returns the failed step and every step downstream of it (that needs it directly or through other steps), in the order runOrder gives. Test data: { publish: ['evaluate', 'report'], evaluate: ['predict'], report: ['predict'], predict: ['features', 'stock'], features: ['validate'], validate: ['extract'], stock: ['extract'], extract: [] }.

JavaScript exerciseRuns in your browser with tests
function runOrder(steps) {
  // steps: { stepName: [names of the steps it needs] }
  // Return all step names in a valid order. Among ready steps,
  // take the alphabetically first. Throw an Error on a cycle.
  return Object.keys(steps);
}

function stepsToRerun(steps, failed) {
  // Return the failed step and everything downstream of it, in run order.
  return [failed];
}
Ctrl + Enter runs
Show a solution
JavaScript
function runOrder(steps) {
  const names = Object.keys(steps).sort();  // alphabetical, so find() picks the first ready step
  const done = new Set();
  const order = [];
  while (order.length < names.length) {
    const ready = names.find((n) => !done.has(n) && steps[n].every((d) => done.has(d)));
    if (ready === undefined) {
      const stuck = names.filter((n) => !done.has(n));
      throw new Error('cycle or missing step among: ' + stuck.join(', '));
    }
    done.add(ready);
    order.push(ready);
  }
  return order;
}

function stepsToRerun(steps, failed) {
  const rerun = new Set([failed]);
  let grew = true;
  while (grew) {  // keep adding steps that need something already in the set
    grew = false;
    for (const [name, needs] of Object.entries(steps)) {
      if (!rerun.has(name) && needs.some((d) => rerun.has(d))) {
        rerun.add(name);
        grew = true;
      }
    }
  }
  return runOrder(steps).filter((n) => rerun.has(n));
}
Pass the exercise to finish this task.

Review a notebook-to-script plan

A teammate sends you this plan for the weekly churn model from Classification and churn, which picks customers for coupons every Monday. List the five biggest risks, and for each one say what you would change.

  1. Export the notebook to one script, churn.py, and run it with cron on the analytics server every Monday at 06:00.
  2. The script reads customer_activity WHERE activity_date >= '2026-01-01'.
  3. It appends the scored customers to the churn_score table, which the coupon tool reads at 09:00.
  4. Packages: whatever is on the server. "If something breaks I'll pip install the latest."
  5. The warehouse password is in the script so cron can run it.
  6. If it fails I'll see it in the cron log when I check on Monday afternoon.
  7. It loads model_final_v3.pkl from my home folder.
  8. Testing: I ran it once and the numbers matched my notebook.
  • You flag the append into churn_score: a re-run doubles customers, and the fix is a run date or run id with overwrite, read by the coupon tool only when published
  • You flag the hard-coded date and the missing run-date parameter, which block re-runs and backfills
  • You flag the silent failure: nobody is alerted before the 09:00 coupon run, and there is no fallback or owner
  • You flag the environment: unpinned packages and a model file in a home folder, fixed with a lock file, an image and a model loaded from a registry or shared storage by version
  • You flag the password in the script and say where it should come from instead
Show a model answer

1. Append without a run key (item 3). A retry or a manual re-run adds every customer again, and the coupon tool sends two coupons. Write scores with a run date, delete and insert that run's rows in one transaction, and have the coupon tool read only the latest run marked published.

2. Hard-coded date, no run-date parameter (item 2). The window grows every week and the script can't be re-run for last Monday. Take --run-date and compute the window from it, for example the 90 days before the run date.

3. Silent failure (item 6). If it fails at 06:00, the coupon tool runs at 09:00 on old or missing scores, and the author finds out in the afternoon. Alert on a non-zero exit and on "no published run by 08:00", name an owner, and decide the fallback (skip this week's coupons or reuse last week's list).

4. Unpinned environment and a model in a home folder (items 4 and 7). A package upgrade or a deleted file changes or stops the scores without any code change. Build an image from a lock file, and load the model by version from shared storage or a registry (ml-03).

5. Password in the script (item 5). Anyone who can read the script or a copy of it has warehouse access. Inject it at run time as an environment variable from a secret store, and rotate the current one.

Also worth raising: one big script with no steps (a failure re-runs everything), and a test plan of one manual run (add unit tests for the features and a sample run in CI). Cron is fine here: one weekly script with an alert does not need an orchestrator yet.

Check yourself

Predict crashed at 00:02 after inserting 1.2 million of tonight's 10 million rows into forecast with a plain INSERT. The orchestrator retried at 00:07 and it succeeded. The ordering system reads every row for tomorrow's date. Which design would have made this night safe?

A buyer's dashboard needs one SQL extract refreshed at 06:00 each day. It is one script with no dependencies, and a late refresh only means a stale chart for an hour. A teammate wants to install a DAG orchestrator for it. What do you suggest?

Monday's forecasts are about 3% higher than Sunday's across most stores. The code, the model file and the input data did not change. On Sunday the image was rebuilt from a Dockerfile containing RUN pip install pandas scikit-learn. What is the most likely cause and fix?

Flashcards

Open my review

Key takeaways

  • A notebook fails nightly work through hidden state, out-of-order cells, manual steps, hard-coded dates and paths, and unpinned packages.
  • Split the work into steps with a contract: inputs for one run date, one output partition or run id, the date passed in, and a clear exit code. The same code then runs tonight and in backfills.
  • Make every step idempotent: write to a place named by the run, overwrite on re-run, publish last. Never append and hope.
  • Schedule the steps as a DAG with retries, backoff, timeouts, a deadline and an alert, do the worst-case time arithmetic, and decide the fallback in advance. Cron is enough for one script.
  • Run everything in one image built from a lock file, keep config and secrets out of the code, and test with unit tests and a twice-run sample in CI.

Sources and further reading

Episode transcriptThe night the notebook needed Tomás · about 11 minutes · tap any line to play from there
  1. NadiaImran, picture a festival week. Three nights in a row, twenty stores order their stock from Monday's forecast. Nothing crashed. Nobody saw an error.
  2. ImranThat sounds like the pilot. Tomás was on leave, wasn't he?
  3. NadiaHe was. The forecast lived in his notebook, and the notebook needed Tomás. Today we turn it into something that runs every night, whoever is on holiday.
  4. ImranFrom notebook to pipeline, then.
  5. NadiaStart with a bakery. A small one, where the owner bakes from memory. A pinch of this, the oven hot enough. Good bread. Then she falls ill in the busiest week, and the shop opens with empty shelves.
  6. ImranAnd a big bakery that supplies fifty shops can't work like that.
  7. NadiaRight. Recipes on cards with exact weights. Stations for mixing, proving, baking and packing, each with a known input and a known output. And a board on the wall saying when each station starts, so the vans leave at six.
  8. NadiaTwo more habits matter. When a tray burns, they bake that tray again and bin the burnt one. Nobody puts both on the shelf. And nothing reaches the van until someone checks it.
  9. ImranWritten recipes, stations, a timetable, redo instead of add, and a check at the end. So what exactly is wrong with Tomás's notebook? It gave good numbers.
  10. NadiaIt did. Twenty-four percent WAPE against the baseline's thirty-four. The model is fine. Running it is the problem, and five things make a notebook unsafe to run at night.
  11. NadiaOne, hidden state. The notebook remembers variables from cells you deleted, so it works on his laptop and fails on a fresh start. Two, cells run out of order. Three, manual steps, like copying a file into a shared folder.
  12. ImranFour and five?
  13. NadiaHard-coded dates and paths, like a date typed into a cell and a folder on his laptop. And unpinned packages, whatever version of pandas or scikit-learn happens to be installed today.
  14. ImranAnd at full scale the job is bigger. Three hundred and twenty stores, roughly two and a half million store-product pairs, four days ahead. About ten million predictions a night.
  15. NadiaSo the first move is to split the work into steps. Extract, validate, build features, predict, evaluate and publish. Plus a training step that runs only on Sundays.
  16. ImranThe first diagram shows that. If you can see your screen, it's a column of six boxes from top to bottom, with start times on the left. Extract at twenty-three fifteen, publish at about twenty to one.
  17. NadiaOn the right, the warehouse feeds extract, the model registry hands the champion model to predict, and there's an alert box beside evaluate. At the bottom, two lines. The one thirty publish deadline, and the two in the morning cut-off when the distribution centres close their orders.
  18. ImranWhat makes something a step, though? Couldn't I cut the notebook into six files and be done?
  19. NadiaEach one needs a contract. Its inputs, for one run date and nothing else. Exactly one output, a partition or rows tagged with one run ID. The run date passed in as a parameter. And an exit code that says success or failure.
  20. ImranWhy pass the date in? The step could ask the computer what day it is.
  21. NadiaThat's the trap. Say a step retries at five past midnight. The clock now says tomorrow, so it looks for a day with almost no sales. And you can never backfill. If the promo feature was wrong for a week in October, you want to rerun those seven dates with tonight's code.
  22. ImranSo the same code serves tonight and every past night. Now retries. Why are they scary?
  23. NadiaBecause a step can fail halfway. Predict crashes at two minutes past midnight after writing one point two million of its ten million rows. The orchestrator retries, and the step writes all ten million again.
  24. ImranAnd if it appended, I'd have one point two million rows twice.
  25. NadiaAnd anything that adds up forecasts per store orders those products double. No error anywhere. People find out when the shelves overflow. We call that append and hope.
  26. ImranThe second diagram is the fix. Three rows. Run one crashes, the retry overwrites the same partition, and only then does publish switch what the readers see.
  27. NadiaExactly. Three rules make a step idempotent, which means running it twice leaves the same result as running it once. Write to a place named by the run date. Overwrite on rerun. And publish last.
  28. NadiaPublish is tiny. It marks tonight's run as published. Until then, the ordering system and the Store app keep reading last night's forecast, so a half-written run is never read.
  29. ImranWhat about skipping a step when its output already exists? That sounds idempotent.
  30. NadiaIt sounds it, but then a half-written partition stays forever. Check for a completion marker instead. And watch side effects, like sending an order file to a distribution centre. Give them a key from the run ID, so the receiver can ignore a repeat.
  31. ImranWho actually runs all this at night?
  32. NadiaAn orchestrator. You describe the steps as a DAG, a directed acyclic graph. Each arrow says this step needs that one, and there are no loops. The orchestrator handles the schedule, the order, retries, timeouts, the deadline and the alerts.
  33. ImranGive me the settings for our forecast.
  34. NadiaStart at twenty-three fifteen, after the till sales land in the warehouse around eleven. Two retries per step, waiting five minutes and then ten. A fifty minute timeout on predict, which normally takes forty. And a page to the on-call engineer if nothing is published by one thirty.
  35. ImranWhy bother with a timeout? If it's slow, it's slow.
  36. NadiaA step that hangs without an error is the worst failure. It never fails, so it never retries, and it eats your slack in silence. The timeout turns the hang into a failure at a known time.
  37. ImranDoes the slack actually cover a retry?
  38. NadiaDo the arithmetic. The run's budget is ninety minutes, so it ends around a quarter to one. That leaves forty-five minutes before the deadline. A fast failure fits easily. One night extract lost its connection, and the retry cost seven minutes.
  39. NadiaBut if predict hangs for its full fifty minutes, the retry finishes predict around one twenty-five, and publish lands near one forty. Too late. So you plan that night in advance. The ordering system falls back to last night's forecast shifted by a day, and a person decides.
  40. ImranDo I always need an orchestrator? Airflow, Dagster, Prefect. It's a lot of machinery.
  41. NadiaFor one script with no dependencies, cron plus an alert on failure is enough. Once you have several steps, dependencies, retries, deadlines and backfills, building that around cron means rebuilding an orchestrator, badly.
  42. ImranAnd the classic, it worked on my machine?
  43. NadiaThat's Docker. One image for the whole pipeline, training included, built from a lock file that lists the exact version of every package. Config, like the warehouse host, comes from a file or environment variables. Secrets come in at run time, never inside the image.
  44. ImranWhy not keep the password in the image? It's private.
  45. NadiaAnyone who can pull the image can read every file in it. Keep it in a secret store and let the orchestrator inject it.
  46. ImranAnd tests?
  47. NadiaThree levels. Unit tests for the feature functions, like checking that a lag feature never sees sales after the run date. An end-to-end run on a five store sample in CI, run twice to prove nothing changes. And data checks every night, which is the next lesson.
  48. ImranBack to the festival week. What did the team actually do?
  49. NadiaKen gave Tomás and me three weeks. Week one, six step scripts, each taking a run date and writing to a dated partition, with publish last. Week two, one Docker image from a lock file, and the DAG with retries, the predict timeout and the one thirty page.
  50. ImranAnd week three was testing?
  51. NadiaUnit tests, the sample run in GitHub Actions, and seven nights in shadow. The pipeline ran next to the notebook and we compared the outputs every morning. They matched within rounding.
  52. ImranAnd the first month?
  53. NadiaThirty out of thirty nights on time. One automatic retry on the extract, seven minutes late, and nobody was woken. Tomás took a week off that month, and nobody messaged him.
  54. ImranThat's the real test of a pipeline. Then all three hundred and twenty stores moved over.
  55. NadiaIf you want to build it, the lesson walks through it. Lay out the project, with features as pure functions and one module that reads and writes partitions. Write each step as a script with a run date argument. Build the image from the lock file.
  56. NadiaThen sketch the DAG, and add a GitHub Actions workflow that runs the unit tests, runs the sample pipeline twice and compares the outputs, and checks the image still builds. Shadow for a week before you switch the readers.
  57. ImranQuestions for my next review, then. If this step runs twice for the same date, what changes?
  58. NadiaAnd the right answer is nothing.
  59. ImranWhere does each step get its date from? What happens at one thirty if nothing is published, and who's told? And can someone other than the author rerun and backfill it?
  60. NadiaGood list. Now one to think about. A teammate's churn script appends scores to a table, and the coupon tool reads that table at nine. The script fails halfway at six, and someone reruns it. What happens? Take a second.
  61. ImranHalf the customers are scored twice, so some of them get two coupons.
  62. NadiaRight. Give it a run date, overwrite on rerun, and have the coupon tool read only the published run.
  63. ImranMy summary. One, a notebook fails at night through hidden state, cell order, manual steps, hard-coded dates and unpinned packages. Two, steps with a contract and the run date passed in. Three, idempotent steps that overwrite and publish last, never append and hope.
  64. NadiaGood. I'd add the deadline arithmetic. Know your worst case, and decide the fallback before the night it happens.
  65. ImranAnd four, one pinned image, and tests that run the pipeline twice.
  66. NadiaNext time, the data going into all this. Late tills, checks that stop a bad run, and how training and serving can quietly compute the same feature two different ways.
Finished reading?Mark it complete to fill in its stop on your course line.

Use it at work

Pipeline template

Build with a dependency cache, unit and end-to-end tests, then staging and production gated on the tests.

Open in the DevOps & TechOps Lab
Next stopData and feature pipelines
Was this lesson helpful?
Questions and discussion

Loading the discussion…