Add NYC taxi medallion Lakeflow declarative pipeline (serverless DAB) - #1
Open
dvannoy wants to merge 4 commits into
Open
Add NYC taxi medallion Lakeflow declarative pipeline (serverless DAB)#1dvannoy wants to merge 4 commits into
dvannoy wants to merge 4 commits into
Conversation
Serverless Lakeflow Spark Declarative Pipeline building a bronze/silver/gold medallion from samples.nyctaxi.trips into catalog `main`. - bronze: append-only streaming table with _ingested_at + stable trip_key hash - silver: streaming table of cleaned/enriched trips (duration, fare_per_mile, ZIP-range borough cross-borough proxy, time-of-day bucket) with expectations dropping non-positive fare/distance and a flag column for reversed timestamps - silver: 500-row deterministic synthetic rider dimension (fully fake PII) - gold: star schema (dim_date, dim_zone, dim_time_of_day, dim_rider, fact_trips) with xxhash64 surrogate keys so keys are stable across refreshes Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fare_per_mile used the raw `/` operator in the same query as the positive_trip_distance expectation. A trip_distance == 0 row can be divided before the expectation drops it, which under ANSI mode raises DIVIDE_BY_ZERO and fails the update. Use try_divide so the result is NULL regardless of the order the optimizer picks; rounding is unchanged. Also address review follow-ups: - Map ZIP 10500-10999 to Westchester, placing the 7 trips whose dropoff ZIP (Yonkers, Mount Vernon, White Plains, Pelham) previously fell through to 'Unknown'. No Unknown regions remain in this dataset. - is_cross_borough is now NULL, not false, when either end is unplaceable, and a new is_region_known column says whether the proxy applied. Previously 'Unknown' != 'Unknown' read as a confident "not cross-borough". - Note that trip_key uniqueness is verified empirically, not by construction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Metric view main.nyctaxi_gold.trips_metrics sourced from fact_trips with declarative joins to dim_date, dim_zone (twice, pickup + dropoff), dim_time_of_day and dim_rider. - 8 measures including revenue_per_mile and cross_borough_share (both try_divide-guarded) and trailing_7day_revenue as a window measure. - Date hierarchy (year > month > date) plus day-of-week rollup, pickup/dropoff zone, time-of-day, and rider dimensions. Rider exposes rider_id and home_zip only -- no name/email/phone/card as dimension attributes. - Deployed as DDL: DABs (CLI v1.9.0) has no metric_views resource type. deploy.sh submits the statement JSON-encoded to /api/2.0/sql/statements/, because `aitools statement submit --file` strips the YAML indentation and the server rejects the definition. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…mings
All five joins in trips_metrics now declare rely: {at_most_one_match: true}.
The hint is truthful: COUNT(*) - COUNT(DISTINCT sk) = 0 on dim_date, dim_zone,
dim_time_of_day and dim_rider, so each join is genuinely at most 1:1. It is
unenforced, which the file header now calls out.
Replaces the prose-only "optimized for fast dashboard reads" claim with numbers
from /api/2.0/sql/history/queries?include_metrics=true on the same serverless
warehouse: the month x time_of_day KPI query runs 1055-1127 ms end-to-end, of
which execution is 0.46-0.48 s and ~0.55 s is metric-view query compilation.
Two more slices (pickup zone, rider) land at 885-1049 ms. EXPLAIN confirms the
join-pruning claim -- only dim_date and dim_time_of_day reach the physical plan,
both as PhotonBroadcastHashJoin, with no shuffle joins.
KPI query output is byte-identical before and after the rely addition.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds
pipelines/nyctaxi-medallion/— a Databricks Asset Bundle containing a serverless Lakeflow Spark Declarative Pipeline that builds a Bronze → Silver → Gold medallion fromsamples.nyctaxi.tripsinto catalogmainacrossnyctaxi_bronze/nyctaxi_silver/nyctaxi_gold.Source schema actually found on
samples.nyctaxi.tripsdatabricks experimental aitools tools discover-schema samples.nyctaxi.tripsreturned exactly six columns, 21,932 rows, zero nulls in every column:tpep_pickup_datetimetpep_dropoff_datetimetrip_distancefare_amountpickup_zipdropoff_zipThere is no primary key, no vendor id, no passenger count, no lat/lon, no
PULocationID/DOLocationID, and no borough or zone column. Two consequences drove the design:sha2(concat_ws('|', pickup_ts, dropoff_ts, trip_distance, fare_amount, pickup_zip, dropoff_zip), 256), materialised astrip_keyin bronze. Verified unique for this snapshot:COUNT(DISTINCT trip_key) = COUNT(*) = 21932. This uniqueness is empirical, not guaranteed by construction — the six columns carry no identity of their own, so two genuinely identical trips (same pickup and dropoff second, same distance, same fare, same ZIP pair) would collide and share atrip_key. It holds for this dataset; a different snapshot would need re-checking.dim_zoneis at ZIP grain, not taxi-zone grain.Cross-borough proxy
pickup_zip/dropoff_zipare the only location columns, so the proxy is a ZIP-range → borough lookup (borough_of()in03_silver_trips_enriched.py). NYC ZIP codes are borough-contiguous, which makes the range test a faithful, haversine-free borough assignment:is_cross_boroughispickup_borough != dropoff_borough, but NULL rather than false when either end is unplaceable —'Unknown' != 'Unknown'would otherwise read as a confident "not cross-borough" when the truth is "we don't know". A companionis_region_knownboolean says whether the proxy applied at both ends at all.The Westchester range (10500–10999) was added during review: it places the 7 trips whose dropoff ZIP is Yonkers (10703/10705/10710), Mount Vernon (10550), White Plains (10601) or Pelham (10803) and which previously fell through to
Unknown. NoUnknownregions remain in this dataset —is_region_knownis true for all 21,847 rows andis_cross_boroughis NULL for none of them, so the three-valued logic is defensive rather than load-bearing here.is_cross_boroughfires on 2,644 of 21,847 silver trips (12.1%) — a plausible rate for Manhattan-dominated 2016 yellow-cab data. Boroughs are also carried as columns on both silver trips anddim_zone, so the proxy is inspectable rather than a black-box flag.Dataset type per layer, and why
Bronze —
nyctaxi_bronze.trips_raw: streaming tableA streaming table is the only Lakeflow dataset type that is append-only by construction: each update processes only source rows that arrived since the last update (exactly-once, pipeline-managed checkpoint) and appends them. That is what makes
_ingested_at = current_timestamp()meaningful — it records when we first saw a row. A materialized view would recompute the full result set on every refresh and reset_ingested_aton all 21,932 rows, which defeats the purpose of a raw layer. The source is a Delta table, read withspark.readStream.table(...), so an append-only batch source is streamed incrementally with no extra machinery.Silver —
nyctaxi_silver.trips_enriched: streaming tableBronze is append-only and every transformation here is row-local: no aggregation, no join, no cross-row state. Streaming bronze's append feed means an update touches only new rows, and expectations are evaluated once per row at ingest instead of on every full recompute. An MV would rescan and re-derive all 21,847 rows each refresh for no benefit.
@dp.expect_all_or_drop({"positive_fare_amount": "fare_amount > 0", "positive_trip_distance": "trip_distance > 0"}). 85 source rows violate (10 withfare_amount <= 0, 76 withtrip_distance <= 0, 1 overlapping), which is exactly the 21,932 → 21,847 delta.is_invalid_time_order = tpep_dropoff_datetime < tpep_pickup_datetime. Rows are kept. This sample happens to contain 0 reversed-timestamp rows (confirmed directly against the source), so the column is present and correct with 0 rows flagged.trip_duration_minutes,fare_per_mile(viatry_divide— see below),pickup_borough/dropoff_borough,is_cross_borough,is_region_known,time_of_day(morning 6–11 / afternoon 12–17 / evening 18–22 / night 23–5),rider_idFK. Liquid-clustered on(pickup_date, pickup_zip).Silver —
nyctaxi_silver.riders: materialized viewA generated 500-row reference dimension. There is no incoming stream to read and nothing to append, so a streaming table is meaningless. Every value is a pure deterministic function of the row ordinal, which makes a full recompute idempotent — precisely MV semantics.
Built with deterministic hashing rather than Faker:
xxhash64(row_ordinal, <per-column salt>)indexes fixed literal lists. This keeps output stable across refreshes with no extra dependency on serverless pipeline compute. No real PII: names/email-domains/home-ZIPs come from literal lists, every email domain is anexample.*reserved domain, every phone number uses the reserved555fictional-use exchange, andcard_last4is a hash digit string.Gold — all five datasets: materialized views
dim_date,dim_zone— these areDISTINCT(i.e. aggregating/deduplicating) queries over the whole silver feed. Streaming tables are append-only and cannot produce a deduplicated result; MVs can, and incrementally refresh as silver grows.dim_zoneunions pickup and dropoff ZIPs before deduplicating.dim_time_of_day— a fixed 4-row lookup built from literals. No stream exists. MV rather than a view so BI joins hit a stored table instead of re-evaluating the generator per query.dim_rider— a projection over a bounded 500-row source that is itself regenerated in place, not appended to; an MV keeps the two in lockstep.fact_trips— the fact is the join point of the star and must stay referentially consistent with four dimensions that are themselves recomputed MVs. An MV refreshes alongside them, so a dimension change (new attribute, re-bucketed time of day) is picked up atomically. A streaming table would freeze rows written under older dimension logic and need a full refresh — which destroys streaming state — to catch up. Liquid-clustered on(date_sk, pickup_zone_sk).Divide-by-zero safety in the silver projection
fare_per_mileis computed withF.try_divide(fare_amount, trip_distance), not the raw/operator. Thepositive_trip_distanceexpectation and this projection are part of the same query, so atrip_distance == 0row can be divided before the expectation drops it; under ANSI mode the raw operator would raiseDIVIDE_BY_ZEROand fail the entire update instead of the row being cleanly dropped.try_divideyields NULL in that case, so the result is correct no matter what order the optimizer picks. Rounding to 4 places is unchanged. Verified: 0 NULLfare_per_milevalues in silver, since all 76 zero-distance rows are dropped by the expectation as intended.Surrogate keys are deterministic, not generated
Every gold SK is
xxhash64(<natural key>)— nevermonotonically_increasing_id():date_sk = xxhash64(cast(date as string))·zone_sk = xxhash64(cast(zip as string))·time_of_day_sk = xxhash64(label)·rider_sk = xxhash64(rider_id)·trip_sk = trip_keyfact_tripsrecomputes FKs with the same expressions the dimensions use, so FKs resolve without a join and can never drift.rider_idon silver trips isRDR-+lpad(pmod(xxhash64(trip_key), 500), 5, '0')— a stable hash of a stable natural key, so the same trip maps to the same rider forever. Norand(), no re-rolling.Verified stable: after a full
--full-refreshof the entire pipeline (bronze reprocessed from scratch), themd5fingerprint of the trip→rider assignment ordered bytrip_keywas byte-identical (1ff9a8f524201c4ad7dcc74e57290c90before and after), as were all row counts and the rider dimension fingerprint (0cf2b1191a8f96397d7803d91f241ba0).Deploy + run confirmation
databricks bundle validate/deploy -t dev -p DEFAULT→ OK. Pipelinenyctaxi_medallion_etl, ID2e6386e7-1a11-4766-8af4-ded34e54eb06,serverless: true— the spec contains no cluster, node type, or autoscale config.0afed5db-010e-4b82-bf03-afe93fce9b45element_atneeds an INT position,pmod(xxhash64(...))is BIGINT. Fixed with an explicit.cast("int").e1ab1919-47a1-463b-8eb8-7510482ccba80bee0c6f-801b-45f1-8de2-d29048e75d68737d4e65-b97e-463e-94fa-bb54419bb007try_divide/ borough fixesThe last update is a full refresh because
is_region_knownis a new column on a streaming table: already-written rows are not reprocessed by an incremental update, so they would have kept NULL for the new column and the oldis_cross_boroughsemantics. A full refresh is safe here precisely because everything is deterministic — the fingerprint check below confirms it reproduced the identical trip→rider assignment.Final row counts
main.nyctaxi_bronze.trips_rawmain.nyctaxi_silver.trips_enrichedmain.nyctaxi_silver.ridersmain.nyctaxi_gold.dim_datemain.nyctaxi_gold.dim_zonemain.nyctaxi_gold.dim_time_of_daymain.nyctaxi_gold.dim_ridermain.nyctaxi_gold.fact_tripsCounts are unchanged from before the review fixes, and the trip→rider fingerprint is still
1ff9a8f524201c4ad7dcc74e57290c90.Referential integrity —
LEFT ANTI JOINfromfact_tripsto each dimension returns 0 orphans on all five FKs (date_sk,pickup_zone_sk,dropoff_zone_sk,time_of_day_sk,rider_sk), and all 500 riders are used by at least one trip.Metric view:
main.nyctaxi_gold.trips_metricspipelines/nyctaxi-medallion/resources/metric_views/trips_metrics.sqldefines a Unity Catalog metric view (YAMLversion: 1.1) over the gold star schema, sourced fromfact_tripswith declarative joins todim_date,dim_zonetwice (pickup_zone/dropoff_zone, offpickup_zone_sk/dropoff_zone_sk),dim_time_of_dayanddim_rider.Deployment is DDL, not a bundle resource. DABs on CLI v1.9.0 has no
metric_viewsresource type, soresources/metric_views/deploy.sh <WAREHOUSE_ID> DEFAULTsubmits theCREATE OR REPLACE VIEW ... WITH METRICS LANGUAGE YAMLstatement. It deliberately does not useaitools statement submit --file: that path strips the YAML block's leading indentation and the server rejects it withMETRIC_VIEW_INVALID_VIEW_DEFINITION ... expected <block end>, but found '-'. JSON-encoding the statement to/api/2.0/sql/statements/preserves whitespace exactly. Both the trap and the fix are documented in the file header.Measures
total_revenueSUM(fare_amount)trip_countCOUNT(1)average_fareAVG(fare_amount)total_milesSUM(trip_distance)revenue_per_miletry_divide(SUM(fare_amount), SUM(trip_distance))average_trip_durationAVG(trip_duration_minutes)cross_borough_sharetry_divide(COUNT(1) FILTER (WHERE is_cross_borough), COUNT(1) FILTER (WHERE is_cross_borough IS NOT NULL))trailing_7day_revenueSUM(fare_amount)+window: [{order: date, range: trailing 7 day, semiadditive: last}]Divide-by-zero handling — both ratio measures use
try_divide, not/. A slice with zero total miles (or zero classifiable trips) yields NULL instead of raisingDIVIDE_BY_ZEROunder ANSI mode and killing the dashboard query. Same reasoning as thefare_per_milefix in the silver layer.NULL handling in
cross_borough_share—is_cross_boroughis NULL when the ZIP-range proxy cannot place an end of the trip. Those rows are excluded from both numerator and denominator, so the measure reads as "share of trips we could classify that crossed a borough." The alternative — counting unknowns in the denominator — would silently dilute the ratio toward zero and present an unclassified trip as a same-borough trip.COUNT(1) FILTER (WHERE is_cross_borough)counts only TRUE, and the denominator counts only non-NULL. On the current data this is a no-op (0 NULLs), so it is defensive rather than load-bearing.Trailing window semantics, verified empirically —
trailing 7 daycovers the 7 days preceding each date and excludes the current date. Confirmed against the data:trailing_7day_revenueon 2016-01-12 is30,551.5, exactly the sum of daily revenue for 2016-01-05 through 2016-01-11.semiadditive: lastreturns the last value whendateis not in theGROUP BY.Dimensions
date(the window-ordering dimension),month=DATE_TRUNC('MONTH', ...),year, plus aday_of_weekrollup (day_name) withday_of_week_numberso day names sort chronologically instead of alphabetically, andis_weekend.pickup_zip/pickup_boroughfrom thepickup_zonejoin, anddropoff_zip/dropoff_boroughfrom thedropoff_zonejoin.time_of_dayplustime_of_day_order(so morning → afternoon → evening → night sorts chronologically, not alphabetically).rider_idas the grain, plusrider_home_zipas coarse geography.full_name,email,phoneandcard_last4are reachable through the join but deliberately not exposed as dimension attributes — a metric view is a governed, broadly-shared surface, and per-rider PII is not needed for any KPI here. (The data is synthetic either way; the point is that the pattern is right.)Dashboard-read performance (measured)
Grain is kept at
fact_tripsrow level with the joins declared in the YAML and pushed down, not pre-joined into a wide denormalized table: the joins are many-to-one on surrogate keys, so the engine prunes any dimension a query does not group by, whereas one wide table would force every query to scan the widest possible row.rely: {at_most_one_match: true}on all five joins. Every join now carries the planner hint, and it is truthful rather than decorative — each dimension's surrogate key is verified unique, so each join really is at most 1:1 and the planner may skip the duplicate-match handling it would otherwise insert:The field is accepted and round-trips in the stored definition —
SHOW CREATE TABLE main.nyctaxi_gold.trips_metricsechoesrely: {at_most_one_match: true}under each of the five joins. Worth flagging: it is unenforced. If a dimension ever gained a duplicate SK the results would be silently wrong, so that uniqueness check belongs in any future gold-layer test suite. (It is also not documented in the installeddatabricks-metric-viewsskill — server-side acceptance is the confirmation.)Measured timings. Warehouse
592a9f85708fccd4(datakickstart_xs, PRO serverless), durations read from/api/2.0/sql/history/queries?include_metrics=true:pickup_borough×pickup_zipslice (25 rows)rider_idslice (25 rows)Straight answer: execution is comfortably sub-second — ~0.34–0.48 s — but end-to-end is ~0.9–1.1 s, not sub-second. Roughly half of every run is metric-view/YAML query compilation (~0.5–0.6 s), which is a fixed cost independent of data volume.
result_from_cachewasfalseon every run, so these are real executions rather than cache hits; the KPI query reads 3 files / 1.38 MB.For comparison, the same three KPI runs before adding
relywere 1297 / 1355 / 1511 ms total. The improvement is real but within noise for a warehouse this size and a fact table this small — the hint's value is at scale, not here.EXPLAINsubstantiates the join-pruning claim. On the KPI query the physical plan contains only 2 joins —dim_dateanddim_time_of_day— withpickup_zone,dropoff_zoneanddim_ridereliminated entirely, and both survivors arePhotonBroadcastHashJoin:Zero
SortMergeJoinand zero shuffle joins. That is the concrete evidence the "don't pre-join into a wide table" choice was right: an unused dimension costs nothing.Other supporting facts —
fact_tripsis liquid-clustered on(date_sk, pickup_zone_sk), the two highest-cardinality join keys this view groups by, so date and pickup-zone filters get file skipping; the four dimensions (60 / 200 / 4 / 500 rows) are broadcast-sized, which the plan above confirms.materialization:is still left off deliberately, documented as a commented-out block in the SQL file. Given the numbers, materializing would attack the ~0.4 s execution, not the ~0.6 s compilation that dominates — so it cannot get this under half a second, and it would stand up an extra hidden Lakeflow pipeline to own and pay for. The file shows the exactaggregatedblock (dimensions[month, time_of_day], measures[total_revenue, trip_count]) to enable if the fact table grows by orders of magnitude.KPI query: revenue and trip count by month and time of day
time_of_day_orderis in theGROUP BYpurely soORDER BYcan sort the buckets chronologically rather than alphabetically. Actual output (all 8 rows):Re-run after adding
rely: {at_most_one_match: true}to all five joins: byte-identical output — same 8 rows, same revenue to the cent, same counts. Trip counts sum to 21,847, reconciling exactly withfact_trips. A cross-check bytime_of_dayalone also returns 21,847 trips withcross_borough_shareranging from 0.086 (morning) to 0.219 (night) andrevenue_per_milefrom 3.77 (night) to 4.53 (afternoon) — night trips are longer-haul and cross boroughs far more often, which is the kind of thing this layer exists to surface.🤖 Generated with Claude Code