refactor(data): make the starter a Databricks template, not a solution - #4
refactor(data): make the starter a Databricks template, not a solution#4lassebenni wants to merge 11 commits into
Conversation
Three changes, all following from decisions taken after this branch was opened: every team uses Databricks, ACA jobs and Airflow, and the starter should hand trainees a shape rather than a finished pipeline. Databricks first. Ingestion lands raw JSON files in the team's Unity Catalog volume instead of upserting into Postgres, dbt targets dbt-databricks against the team catalog, and staging reads the volume with read_files. Postgres stays in the picture as the backend's database, which is what the pipeline publishes into, not where raw data lives. Airflow owns three steps: trigger the Container Apps job, build dbt, then publish the mart into the backend database. The publish step exists because a mart nobody can read is not a data product, and it runs after dbt so a failed test never reaches the backend. Template, not solution. The wiring, the config loading and the conventions are finished. land_raw_json, the sync, and the three DAG task bodies raise NotImplementedError with a docstring saying what they must do. The dbt models are skeletons carrying the naming conventions and the contract .yml. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Raw JSON now lands in an ADLS Gen2 container the team creates themselves, which is what week_15__7_architecture_stacks.md already describes, rather than going straight into a Unity Catalog volume. The win is authentication. The container writes with DefaultAzureCredential: the trainee's az login locally, the Container Apps job's managed identity in Azure. No Databricks token is involved in ingestion, and account keys are switched off on the storage account, so there is no key to put in .env. infra/ is bicep for the storage account, the raw container, a lifecycle rule and the role assignment for the job identity. It replaces the bare storage account under optional/, because storage is now on the critical path. dbt is untouched. A volume in the team's catalog points at the container, so models keep reading /Volumes/<catalog>/landing/raw and never name the storage account. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Each team has its own Databricks service principal, so dbt and the sync use OAuth client credentials instead of a personal access token. A personal token means the pipeline runs as whoever pasted it, breaks when they rotate it, and keeps working after they leave. Both values live in Key Vault as fp-databricks-client-id-team-<x> and fp-databricks-client-secret-team-<x>. The team's Airflow VM has an identity allowed to read its own team's secrets and nobody else's, so the DAG fetches them at run time and nothing is stored on the VM. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
DATABRICKS_HTTP_PATH was left blank; it is the shared warehouse and now verified end to end with a team service principal. The infra README said <your-rg>, but role assignments only succeed in rg-hyf-data, so the second deploy would have failed with AuthorizationFailed and looked like a bug in the template. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Refactors the data starter into a Databricks-first training template using ADLS, ACA jobs, Airflow, dbt, and backend Postgres publishing.
Changes:
- Replaces Postgres raw storage with ADLS and Databricks models.
- Adds three-stage Airflow orchestration and outbound synchronization scaffolding.
- Promotes Azure infrastructure and removes obsolete optional modules.
Reviewed changes
Copilot reviewed 25 out of 25 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
data/src/sync.py |
Adds backend publishing template. |
data/src/storage.py |
Replaces Postgres writes with ADLS landing template. |
data/src/pipeline.py |
Wires ingestion to raw-file landing. |
data/src/config.py |
Adds storage-oriented configuration. |
data/README.md |
Documents the new architecture and workflow. |
data/pyproject.toml |
Adds Azure, Databricks, and sync dependencies. |
data/optional/README.md |
Removes promoted optional components. |
data/optional/databricks/README.md |
Removes obsolete Databricks upgrade guide. |
data/optional/bicep/modules/storage.bicep |
Removes old optional storage module. |
data/optional/bicep/main.bicep |
Removes old optional deployment entry point. |
data/infra/README.md |
Documents required Azure infrastructure. |
data/infra/modules/storage.bicep |
Provisions secured ADLS storage. |
data/infra/main.bicep |
Adds deployment and role assignment wiring. |
data/Dockerfile |
Installs Azure storage dependencies. |
data/docker-compose.yml |
Reframes Postgres as the backend database. |
data/dbt/tests/assert_posted_at_not_in_future.sql |
Adapts timestamp syntax for Databricks. |
data/dbt/profiles.yml |
Switches dbt to Databricks OAuth. |
data/dbt/models/staging/stg_postings.sql |
Reads raw files through read_files. |
data/dbt/models/staging/_sources.yml |
Removes the Postgres source declaration. |
data/dbt/models/marts/fct_postings.sql |
Updates the mart for Databricks staging. |
data/dbt/models/marts/_fct_postings.yml |
Updates the tags contract description. |
data/dbt/dbt_project.yml |
Adds the landing-volume path variable. |
data/airflow/requirements.txt |
Replaces embedded dbt dependencies. |
data/airflow/dags/pipeline_dag.py |
Adds ingest, dbt, and publish orchestration. |
data/.env.example |
Adds storage, Databricks, and backend settings. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| account=config.storage_account, | ||
| container=config.storage_container, | ||
| blob_path=blob_name(config.source_name, run_date), | ||
| records=[record.model_dump(mode="json") for record in parsed], |
| created_at as posted_at, | ||
| source_file, | ||
| ingested_at | ||
| from source |
| bash_command=( | ||
| "uvx --python 3.11 --from 'dbt-core==1.10.*' " | ||
| "--with 'dbt-databricks==1.10.*' " | ||
| "dbt build --project-dir /opt/airflow/include/dbt --profiles-dir /opt/airflow/include/dbt" |
| AZURE_RESOURCE_GROUP, ACA_JOB_NAME, | ||
| DATABRICKS_HOST, DATABRICKS_HTTP_PATH, | ||
| DATABRICKS_CLIENT_ID, DATABRICKS_CLIENT_SECRET, | ||
| DATABRICKS_CATALOG, DBT_SCHEMA, | ||
| BACKEND_PG_HOST, BACKEND_PG_DB, BACKEND_PG_USER, BACKEND_PG_PASSWORD |
| The pattern that avoids a half-written table is write-then-swap: | ||
|
|
||
| 1. create <table>_new | ||
| 2. insert every row into it | ||
| 3. rename <table> -> <table>_old, <table>_new -> <table>, drop <table>_old | ||
| (all three inside one transaction) | ||
|
|
||
| Readers see the old table until the swap, then the new one. They never see an | ||
| empty or partial table. |
| run_date: the day this run belongs to, as YYYY-MM-DD. Airflow passes | ||
| its logical date so a re-run of an old day overwrites that day's | ||
| file rather than today's. |
Ingestion now uploads to /Volumes/<catalog>/landing/raw through the Databricks Files API, using the same team credential dbt already uses. That removes the storage account, its bicep, the role assignment that only succeeds in one resource group, and the teacher step that had to create an external location before anything in dbt could run. Verified end to end against team_a: the upload returns 204 and read_files() reads the rows back. An empty batch now fails the run instead of reporting success, because landing zero rows leaves yesterday's mart in place with every test still passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
datetime.fromtimestamp without a timezone uses whatever zone the machine is in, so the same record produced a different posted_at on a laptop than in the container. Raised by review on PR #1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
payload.get("data", payload) raises AttributeError when the API returns a
list at the top level, which is the shape the docstring claimed to support.
Anything that is not a list now fails with a message naming the type it got.
Raised by review on PR #1.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The compose service became backend-db and this file still said db, still set POSTGRES_DB=finalproject where compose sets backend, and told students to start a service named postgres that does not exist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The documented streamlit command failed on a clean checkout: neither streamlit nor pandas was in pyproject.toml. Raised by review on PR #1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Findings from the end-to-end rehearsal on team A's real infrastructure: - The Airflow image has no az binary and no Container Apps provider, so the ingest task is an HTTPS call to the management API with a token from the VM's own identity. The DAG now ships azure_token() and keyvault() rather than pointing students at a CLI that is not installed. - Secrets move from Airflow variables to Key Vault, fetched per run. - V2__analytics_schema.sql adds the schema and both sync roles as a migration in the backend repo, where the boundary belongs. ALTER DEFAULT PRIVILEGES names the writer role: without FOR ROLE the application cannot read a single mart, because the sync creates a new table on every publish.
'dbt-core==1.10.*' resolves to 1.10.13, and dbt-databricks 1.10.11 requires dbt-core <1.10.10, so the wildcard pair no longer resolves at all. A rehearsal run on the team A VM failed on exactly this. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Superseded by #9, which lands the final state of both branches on current This one no longer merges: #9 carries the six live review comments from #4 as fixes, and notes in its description which comments here are already stale (the naive datetime, the undeclared Streamlit dependencies, the Leaving this open for you to close. |
What I built
Rebases the data starter onto the stack every team will actually use (Databricks, ACA jobs, Airflow) and turns it from a working pipeline into a template with
TODOs. Targetsfeat/data-starterso it lands as a review on top of #1 rather than a competing branch.Oversized: it rewrites the same starter #1 introduced, so splitting it would leave the branch in a half-Postgres, half-Databricks state that no reviewer could evaluate.
Why this approach
Three decisions taken after #1 was opened:
Postgres stays, but in its real role: the backend's database, which the pipeline publishes into, not where raw data lives. Raw lands as files in the team's Unity Catalog volume, because a file is exactly what the source sent and can be re-read when a column changes shape.
Staging reads the volume with
read_filesrather than a declared dbt source, so a new day's file needs no code change.Considered and rejected: keeping a Postgres path for local development alongside Databricks. It gives every concept two implementations and doubles what a trainee has to hold in their head.
Contract impact
None.
fct_postingskeeps the same columns and the same_fct_postings.yml. One description changed:tagsno longer mentions Postgres JSONB operators, since it is a Databricks array until the sync writes it out.How to run
Self-check
What changed
Databricks first.
src/storage.pylands raw JSON in/Volumes/<catalog>/landing/raw/<source>.dbt/profiles.ymltargetsdbt-databricksagainst the team catalog.Three-step DAG.
ingest(trigger the ACA job and wait) →dbt_build(uvx on Python 3.11 withdbt-databricks) →publish_to_backend. Publishing runs after dbt, so a failed test never reaches the backend.Template, not solution. Finished:
config.py,ingest.py, thepipeline.pywiring, the Dockerfile, the contract.yml, the two custom tests. Left to the trainees, each with a docstring saying what it must do:land_raw_json,landing_file_name,read_mart,publish, and the three DAG task bodies.Local dev.
docker composestarts one Postgres namedbackend-db, standing in for the backend database so the sync can be built before the real one exists.Removed
optional/databricks/, which described Databricks as an optional upgrade from Postgres.🤖 Generated with Claude Code