Skip to content

feat(data): working end-to-end data pipeline starter - #1

Open
lassebenni wants to merge 6 commits into
mainfrom
feat/data-starter
Open

feat(data): working end-to-end data pipeline starter#1
lassebenni wants to merge 6 commits into
mainfrom
feat/data-starter

Conversation

@lassebenni

@lassebenni lassebenni commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

What I built

A working end-to-end starter for the data/ folder: fetch from a source API, validate, store, shape with dbt, publish a mart the backend reads. Plus the PR template and checks this description is now demonstrating.

Why this approach

One composed starter, not one template per tool. Week 15 is synthesis: a team ships one pipeline. Five parallel scaffolds (dbt, Airflow, Databricks, Docker, Python) would make the two data trainees spend day one picking tools instead of building, and would duplicate the ingestion, config, and secrets layer five times. Databricks, Bicep, and Streamlit sit under optional/, matching how week_15__2_project_requirements.md already marks them.

It runs on clone with no credentials. The default source is the Arbeitnow job board, which needs no key, and docker compose up -d db gives a local Postgres. A team's first hour goes into their product rather than setup.

The mart is the contract with backend/. dbt/models/marts/_fct_postings.yml documents every published column, and docs/mart_contract.md sets out which changes are safe alone (add a column) and which need agreement (rename, remove, retype). That is what lets the backend write endpoints before the pipeline is finished.

Contract impact

None. This is new scaffolding in an empty folder; no existing mart or endpoint changes.

How to run

cd data
cp .env.example .env
docker compose up -d db
uv venv && uv pip install -e ".[dbt]"
uv run python -m src.pipeline
cd dbt && uv run dbt build --profiles-dir .

# the way Azure will run it
docker compose run --rm pipeline

# orchestrated
cd airflow && cp .env.example .env && astro dev start

Self-check

  • I ran this and it works
  • Tests pass locally
  • No secrets, tokens, or connection strings in the diff
  • This pull request does one thing

Verified end to end, not assumed:

Aspect Result
docker build builds
Pipeline from host 175 records, 0 rejected
Pipeline in container 175 rows written
Idempotency (second run) still 175 rows
dbt build from host PASS=18 ERROR=0
dbt build inside Airflow PASS=18 ERROR=0
astro dev start stack boots
Full DAG run state=success, both tasks

Four real defects were found by running it rather than reading it: the container could not reach Postgres via localhost; the DAG used Airflow 2 imports against a pinned Airflow 3 runtime; data/airflow/ was not a valid Astro project and my own .gitignore made it permanently unfixable; and the DAG was silently connecting to Airflow's own metadata database, because Astro ships a service also called postgres. All fixed, details in the comments below.

Oversized: initial scaffold for an empty folder, 32 files that only make sense together. Splitting it would produce several PRs that individually do nothing.

🤖 Generated with Claude Code

lassebenni and others added 3 commits August 3, 2026 08:48
Composed starter rather than one template per tool: Week 15 is synthesis, so a
team ships one pipeline, not five parallel scaffolds.

Runs on clone with no credentials: the default source is the Arbeitnow job
board (no API key) and docker compose brings up a local Postgres. Verified
end to end against a throwaway database: 175 rows ingested, dbt build green
with PASS=18 ERROR=0, and a second pipeline run leaves the row count at 175
because writes are upserts.

The mart plus its .yml is the contract with backend/, so the backend can write
endpoints before the pipeline is finished and the single frontend trainee is
not blocked. docs/mart_contract.md covers how the two pairs agree and change it.

Bicep, Databricks, and Streamlit ship under optional/ because Week 15 marks
them as optional extensions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The repository shipped a Node-only .gitignore, so a trainee running the data
or backend starter would commit .venv, __pycache__, dbt target/, and compiled
classes on day one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two defects found by actually running what the README told trainees to run.

docker run --env-file .env failed: POSTGRES_HOST=localhost points at the
container itself, not the host, so the pipeline could never reach the compose
database. Added a pipeline service that joins the compose network and
overrides the host, so 'docker compose run --rm pipeline' works.

The DAG parsed but raised three deprecation warnings against the pinned Astro
runtime 3.3, which is Airflow 3. Switched to airflow.sdk and the standard
provider BashOperator. It now parses clean with both tasks present.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@lassebenni

Copy link
Copy Markdown
Collaborator Author

Verification update: the two unverified items are now done, and both had defects

Docker was unavailable when this PR was opened. Re-ran both checks and each one found a real bug, now fixed in b2ab975.

1. docker build passes, but the documented run command did not

The image builds. Then docker run --env-file .env final-project-data failed:

connection to server at "127.0.0.1", port 5432 failed: Connection refused

Inside a container localhost is the container, not the host, so POSTGRES_HOST=localhost from .env.example could never reach the compose database. This is exactly the day-one blocker the template exists to remove.

Fixed by adding a pipeline service to docker-compose.yml that joins the compose network and overrides the host. Verified from inside the container:

POSTGRES_HOST resolved to: postgres
rows written: 1
CONTAINER -> POSTGRES OK

The README now shows both paths and explains why they differ.

2. The DAG parsed, but against a deprecated API

Parsed in the Astro runtime image: import_errors: {}, so it was never broken. But it emitted three deprecation warnings, because the pinned runtime:3.3-2 is Airflow 3:

  • airflow.decorators.dag and .task are now airflow.sdk
  • airflow.operators.bash.BashOperator is now airflow.providers.standard.operators.bash.BashOperator

Shipping deprecated imports in a starter teaches them to every trainee who copies it. Switched to the current API. Re-parsed clean:

import_errors: {}
DAG: final_project_pipeline | tasks: ['dbt_build', 'ingest']

Still not verified

The DAG parses and its tasks are correct, but I have not executed a scheduled run end to end. Worth one astro dev start before trainees see this.

🤖 Generated with Claude Code

Running 'astro dev start' exposed three defects that DAG parsing alone could
not: the folder was not an Astro project at all, the runtime's ONBUILD step
requires a packages.txt that did not exist, and the DAG imported src and read
include/dbt, neither of which was reachable from the containers.

- .astro/config.yaml added, and un-ignored at the repo root. Without it astro
  refuses to run, so ignoring it made the folder permanently broken.
- packages.txt added. The Astro runtime ONBUILD-copies it and the build fails
  with 'packages.txt: not found' when it is missing.
- docker-compose.override.yml bind-mounts ../src and ../dbt into the Airflow
  containers and sets PYTHONPATH, so there is one copy of each rather than a
  duplicate that drifts.
- requirements.txt now carries the pipeline's own dependencies, which the
  ingest task needs to import src.pipeline.

Verified with the stack running: the DAG is listed with no import errors,
src.pipeline imports inside the scheduler, include/dbt is mounted, and the
dbt_build task command finishes PASS=18 ERROR=0 against a real database.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@lassebenni

Copy link
Copy Markdown
Collaborator Author

astro dev start run: three more defects, all fixed in 5f41c9d

Parsing the DAG was not enough. Actually booting the stack found three things that would each have stopped a trainee on day one.

1. data/airflow/ was not an Astro project.

Error: this is not an Astro project directory.

It had no .astro/config.yaml. Worse, the .gitignore I added in b503f40 ignored .astro/ wholesale, so the file could never be committed and the folder was permanently broken. Root .gitignore now ignores only .astro/config.yaml.lock.

2. The image could not build without packages.txt.

ERROR: failed to build: "/packages.txt": not found

The Astro runtime ONBUILD-copies packages.txt, so it is mandatory even when empty. Added, with a comment saying OS packages go there and Python packages go in requirements.txt.

3. The DAG referenced code the containers could not see.

ingest does from src.pipeline import run and dbt_build reads /usr/local/airflow/include/dbt. Neither existed inside the Astro project. Rather than duplicate the pipeline and dbt project into airflow/ (which drifts), added a docker-compose.override.yml that bind-mounts ../src and ../dbt and sets PYTHONPATH. One copy of each.

Verified with the stack actually running

dag_id                 | fileloc                                 | owners
final_project_pipeline | /usr/local/airflow/dags/pipeline_dag.py | data-team
import errors: No data found

src package importable inside Airflow: /usr/local/airflow/src/pipeline.py
include/dbt mounted: dbt_project.yml  models  profiles.yml  seeds  macros

dbt_build task command:
Done. PASS=18 WARN=0 ERROR=0 SKIP=0 NO-OP=0 TOTAL=18

Still not verified, and why

The ingest task has not run inside Airflow. Containers in my environment have no outbound DNS, so the fetch fails with Failed to resolve 'www.arbeitnow.com' before it reaches the database. The same code path is verified from the host, where it fetched 175 live records. On a normal machine with working container networking this should pass, but it is worth one astro dev start plus a manual DAG trigger to confirm.

🤖 Generated with Claude Code

Booting Airflow against the data stack exposed a service name collision. The
Astro stack ships a service called postgres, so a DAG configured with
POSTGRES_HOST=postgres resolved to Airflow's metadata database rather than the
project's, and failed with 'database finalproject does not exist'.

- Renamed the data service from postgres to db, and said why in a comment so
  nobody renames it back.
- Gave the data stack a fixed network name, finalproject, so the Airflow
  stack can attach to it by name instead of going through
  host.docker.internal, which breaks on any machine already using port 5432.
- Added airflow/.env.example. The folder previously shipped no environment
  file at all, so the DAG had nothing to read.

Verified with a real run: DagRun state=success, both tasks. The ingest task
fetched 175 live records and dbt built the mart from them, PASS=18 ERROR=0.
fct_postings.ingested_at falls inside the run window, which is what proves the
data came from that run rather than an earlier one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@lassebenni

Copy link
Copy Markdown
Collaborator Author

Everything is now tested, including the ingest task. One more real defect found.

My earlier note said containers had no outbound DNS. That was wrong: the Docker daemon had only just started when I tested it. Retested and DNS resolves fine, so I ran the whole thing properly.

The defect: the DAG was talking to Airflow's own metadata database

With the containers wired onto the data stack's network, the DAG failed:

connection to server at "172.20.0.2", port 5432 failed:
FATAL: database "finalproject" does not exist

The Astro stack ships its own service named postgres. A DAG configured with POSTGRES_HOST=postgres resolves to Airflow's metadata database, not the project's. Silent, and it would have cost a team hours.

Fixed in 4cb4d6a:

  • Data service renamed postgres to db, with a comment explaining why so nobody renames it back.
  • The data stack now has a fixed network name, finalproject, and the Airflow containers attach to it. Better than host.docker.internal, which breaks on any machine already running something on 5432 (mine does).
  • Added airflow/.env.example. The folder shipped no environment file at all, so the DAG had nothing to read.

Full end-to-end run

DagRun Finished: dag_id=final_project_pipeline, state=success, run_duration=8.53s

raw.postings                -> 175
analytics.fct_postings      -> 175
fct_postings.ingested_at    -> 2026-08-03 21:28:21

The run window was 21:28:18 to 21:28:27, so ingested_at at 21:28:21 proves the ingest task fetched live data during that run rather than reusing an earlier load. dbt then built the mart from it: PASS=18 WARN=0 ERROR=0.

Verification status: complete

Aspect Status
docker build ✅ builds
Pipeline from host ✅ 175 records, 0 rejected
Pipeline in container (docker compose run) ✅ 175 rows written
Idempotency (second run) ✅ still 175 rows, upsert works
dbt build from host ✅ PASS=18 ERROR=0
dbt build inside Airflow ✅ PASS=18 ERROR=0
astro dev start ✅ stack boots
DAG parse ✅ no import errors
Full DAG run, both tasks state=success

Nothing on the data side is unverified now.

🤖 Generated with Claude Code

Adapted from the pr-body-check.yml already running in data-assignment-week-9,
11, and 12, with the size gate added.

The template alone is not enough. GitHub only auto-fills it in the web compose
form and in 'gh pr create' with no --body, so a pull request opened through the
REST API or with --body, which is the path most AI tools take, silently skips
it. The check is the only thing that actually enforces it.

Sections are the assignment-repo set with one addition: 'Contract impact'. It
makes the author state whether a mart the backend reads has changed, before
merging rather than after, which is what turns the CODEOWNERS rule into a
conversation.

The size gate exists because an unreviewable pull request is a size problem
before it is an AI problem. Two thousand lines does not get reviewed, it gets
approved. The limit is a number so that a bot raises it rather than one
teammate having to tell another their work is too big to read. Generated files
are excluded, and an 'Oversized: <reason>' line in the description overrides it
so the exception stays visible.

Both re-run on description edits, so recovery needs no new commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant