Skip to content

Stage 3: rewrite as Lakeflow Spark Declarative Pipelines - #3

Open
p17b wants to merge 7 commits into
medallion_whlfrom
medallion_sdp
Open

Stage 3: rewrite as Lakeflow Spark Declarative Pipelines#3
p17b wants to merge 7 commits into
medallion_whlfrom
medallion_sdp

Conversation

@p17b

@p17b p17b commented Jul 20, 2026

Copy link
Copy Markdown
Owner

Third of three stages, stacked on medallion_whl.

Replaces the ingest / transform / aggregate jobs with a single pipeline that
declares three datasets. Execution order is derived from table references in the
code; the runtime owns checkpoints, triggers, output modes, and the writes.

The transformation logic is unchanged. Both branches import the same
transform_silver and build_gold from the wheel, covered by the same 43 unit
tests. Only orchestration differs — which is what makes the two comparable, and
what the wheel from stage 2 bought us.

What disappears

Concern Job-based This branch
Execution order Three jobs run in sequence Derived from table references
Checkpoints Explicit path per stream Managed by the pipeline
Triggers / output modes Per write Implicit in the dataset type
Writes saveAsTable + overwriteSchema The decorator's return value
Quality reporting A job asserting counts afterwards Per-run metrics in the event log

Three job YAMLs and three notebooks deleted; three transformation files and one
pipeline YAML added.

Dataset types

Bronze and silver are streaming tables — append-only, incremental.

Gold is a materialized view, deliberately. Streaming tables never revisit
rows they have already emitted, so a daily aggregate built as one would go stale
the moment late or corrected data reached silver. It reads silver with
spark.read, not readStream: a streaming read would impose watermark and state
constraints for no benefit, since the anomaly baseline spans the whole history
rather than a window.

Expectations

Every one is expect_all — warn and keep, never _or_drop or _or_fail. That
is the declarative form of a decision made on stage 1: invalid readings are
flagged, not discarded. Dropping them hides sensor faults from the people who
fix them; failing the update would take the pipeline down over a few bad rows in
an otherwise good delivery.

Silver's conditions reference the boolean columns the transform already produces
rather than restating the rules in SQL — one source of truth, so the rule cannot
drift between column and expectation.

The metrics reproduce the fixture manifest independently:

Expectation Failed Manifest expects
turbine_id_in_expected_group 27 27
power_output_present_and_non_negative 9 9
wind_speed_present_and_non_negative 10 10
wind_direction_within_compass_range 8 8
source_file_maps_to_known_group 20 20

Summing to the manifest's 54 invalid readings. Two mechanisms, completely
different routes, same numbers. Gold's three expectations pass 465/465.

Results

Identical to the job branches: silver 11,169 / 54 invalid / 20 UNKNOWN / 465
turbine-days / 20 anomalies. The stage 1 validation job passes unchanged
against pipeline-produced tables — a fair cross-check rather than a rewritten
test.

Migration constraint

The first run failed:

Could not materialize `...`.`turbine_raw` because a MANAGED table already
exists with that name.

A pipeline cannot adopt an existing managed table. The job branches had
written plain Delta tables at those names, and they had to be dropped so the
pipeline could own them. Harmless for regenerable fixtures; on a real migration
it means a cutover plan — write to new names and swap, or accept a rebuild
window.

Trade-offs

For: far less orchestration code; dependency graph derived rather than
maintained; quality is a queryable per-run artifact; table types force an
explicit answer to "recompute or append?".

Against: the pipeline owns its tables, so migration is a cutover; debugging
moves from reading a notebook top-to-bottom to reading a graph plus an event
log; and it is Databricks-specific in a way that plain PySpark plus a scheduler
is not.

Neither is strictly better. Declarative wins when the shape is stable and
quality reporting matters; the job version is easier to reason about while the
logic is still moving, and it ports.

See docs/declarative_pipeline.md for the full write-up.

p17b and others added 7 commits July 20, 2026 09:38
Replace the ingest/transform/aggregate jobs with a single pipeline declaring
three datasets. Execution order comes from the table references in the code
rather than from running jobs in sequence, and the runtime owns checkpoints,
triggers, output modes, and the writes themselves.

The transformation logic is untouched: both branches import the same
transform_silver and build_gold from the windmill wheel, covered by the same 43
unit tests. Only orchestration differs, which is what makes them comparable.

Bronze and silver are streaming tables. Gold is a materialized view rather than
a streaming table, because streaming tables never revisit rows they have already
emitted -- a daily aggregate built as one would go stale the moment late or
corrected data reached silver. It reads silver with spark.read, not readStream:
a streaming read would impose watermark and state constraints for no benefit,
since the anomaly baseline spans the whole history rather than a window.

Every expectation is expect_all -- warn and keep, never _or_drop or _or_fail.
That is the declarative form of a decision made on the first branch: invalid
readings are flagged, not discarded. Dropping them hides sensor faults from the
people who fix them, and failing the update would take the pipeline down over a
few bad rows in an otherwise good delivery. Silver's conditions reference the
boolean columns the transform already produces instead of restating the rules,
so the rule cannot drift between column and expectation.

Expectation metrics reproduce the fixture manifest independently: 27 bad turbine
ids, 9 bad power readings, 10 bad wind speeds, 8 bad bearings, 20 unknown-group
rows -- summing to the manifest's 54 invalid readings. Gold's three expectations
pass 465/465. Results match the job branches exactly: silver 11169, 54 invalid,
20 unknown, 465 turbine-days, 20 anomalies, and the stage 1 validation job
passes unchanged against pipeline-produced tables.

One migration constraint found the hard way: a pipeline cannot adopt an existing
managed table. The first run failed because the job branches had written plain
Delta tables at those names; they had to be dropped so the pipeline could own
them. Harmless for regenerable fixtures, but on a real migration it means a
cutover plan rather than a switch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add three sections to the README.

Implementation branches -- what each of the three stages adds, a comparison
across orchestration, testability, quality reporting, table ownership,
migration cost and portability, and the note that all three produce identical
results with the stage 1 validation job passing unchanged on each. That last
part is what makes the comparison fair rather than three separate claims.

Environments and isolation -- how one name_prefix line per target separates
schemas as well as job names, why that makes end-to-end testing safe (a service
principal can exercise ingestion through assertions with no blast radius on real
data), and how a preprod target for full-volume performance and quality testing
is a target block rather than an architecture change.

Write-Audit-Publish -- the pattern the target isolation enables. Write and Audit
are implemented here; Publish is explicitly not, since there is no production
target to promote into and building one would be scaffolding around a
hypothetical. The shape a promotion step would take is described instead. Also
records why the two audit mechanisms are kept independent: expectations report
what the pipeline saw, the validation job asserts what it should have seen
against an independently generated manifest, and neither can catch the other's
failures.

Switching between branches documents that a destroy is required first, and why:
a pipeline cannot adopt an existing managed table, so job-written tables must be
dropped before the pipeline can own them. Harmless for regenerable fixtures, a
cutover plan on a real migration.

Note the pattern's established name is Write-Audit-Publish, not Write-Ahead-
Publish -- the audit gate is the middle step, and it is the one usually skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The branch comparison read as a feature matrix aimed at someone who already
knew the platform. Rewrite it around what each stage was for -- make it work,
make it testable, make it declarative -- with a short "best when" table instead
of eight rows of implementation detail. The trade-off that actually matters
(sdp writes the least code but ties you to Databricks and needs a cutover to
leave) is stated plainly rather than spread across rows.

Promote the DEV and TEST targets to their own section near the top. That
isolation is the point of the setup: the same code runs against different data
in separate schemas, so a user or service principal can exercise the pipeline
end to end without impacting real data, and preprod for full-load performance
and quality testing is a config block rather than an architecture change.

Add four mermaid diagrams: branch progression, environment isolation, the
Write-Audit-Publish flow, and the pipeline DAG replacing the ASCII art. GitHub
renders these inline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The task asks for a brief description of the solution design and the assumptions
made. The README opened straight into branch detail, so a reader had to work
through several sections before reaching either. Add a TL;DR covering what was
built, why it exists in three implementations, the environment separation that
makes end-to-end testing safe, and the two decisions that shape everything else
-- flagging invalid readings rather than dropping or imputing them, and computing
anomaly baselines over daily means rather than raw readings.

scripts/export_chat.py converts a Claude Code session transcript to Markdown.
The raw JSONL is ~4.5 MB of interleaved prose, tool calls and tool output;
verbatim it is unreadable, so prose is kept in full while tool input and output
are truncated to a collapsed preview. Harness-injected blocks are stripped, and
sub-agent threads are skipped.

The generated docs/opus_chat.md is gitignored. It is a working record rather than
part of the deliverable, and it carries the full back-and-forth instead of the
conclusions -- which are what the docs are for. Regenerate it locally when
wanted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
docs/architecture.md was written during initial scaffolding and never updated. It
asserted behaviour the pipeline does not have and, in two places, the exact
opposite of the documented design:

  Silver Layer: Forward-fill missing values (by turbine)
  Silver Layer: Outlier removal (>2 std dev from mean)

Both were deliberately rejected -- forward-filling invents measurements that were
never taken, and removing outliers deletes the deliverable the task asks for. It
also listed an efficiency score that was never implemented, and an assumption
that sensor gaps are forward-fillable. A reader would have concluded the pipeline
does the reverse of what the README argues for. Same class of problem as the dead
src/ package removed earlier: documentation drifting into a claim about
behaviour.

Rewritten around the decisions and what was rejected, since the README already
covers what it does and how to run it. Adds the silver/gold split rationale (a
check belongs in silver if one row can judge it), the sqrt(24) baseline defect
and its fix, per-turbine rather than fleet-wide baselines, the >= 0 boundary, why
gold is a materialized view rather than a streaming table, and an honest known-
gaps section.

Co-Authored-By: Claude Opus 4.8 (1M context) <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