Developing Silver Pipeline for AUTO CDC Sources
Estimated read: 15 min
Lecture 2 planned tag, split, then merge for all three CDC-sourced tables. This lecture builds it
– a two-tier tagging helper, one fully worked table with a referential join, and the AUTO CDC
flows that turn a clean stream into full SCD Type 2 history.
Extending Section 2’s tagging helper for two severities
Bronze’s tag_quality produced one pass/fail flag. Silver needs two tiers – critical and
warning – so a new helper builds both from two separate rule dictionaries:
# transformations/dq_helpers.py (addition)
from pyspark.sql.functions import when, lit, array, array_except, size, col
def tag_business_rules(df, critical_checks: dict, warning_checks: dict):
critical_flags = array(*[when(cond, lit(name)) for name, cond in critical_checks.items()])
warning_flags = array(*[when(cond, lit(name)) for name, cond in warning_checks.items()])
return (
df
.withColumn("_dq_critical_failures",
array_except(critical_flags, array(lit(None).cast("string"))))
.withColumn("_dq_critical_failed", size(col("_dq_critical_failures")) > 0)
.withColumn("_dq_warnings",
array_except(warning_flags, array(lit(None).cast("string"))))
)
An empty critical_checks dictionary (customers, below) is a valid input – _dq_critical_failed
just comes back false for every row, and every row proceeds to the merge.
Worked example: silver_orders
# transformations/silver_cdc.py
import dlt as dp
from pyspark.sql.functions import col, current_date
from dq_helpers import tag_business_rules
KNOWN_STATUSES = ["PLACED", "SHIPPED", "DELIVERED", "CANCELLED"]
@dp.table(name="silver_orders_tagged", comment="orders tagged with business rule outcomes")
def silver_orders_tagged():
orders = spark.readStream.table("dev.step_right.bronze_orders_valid")
known_customers = (
spark.read.table("dev.step_right.bronze_customers_valid")
.select(col("customer_id").alias("_known_customer_id"))
.dropDuplicates()
)
joined = orders.join(
known_customers, orders.customer_id == known_customers._known_customer_id, "left"
)
critical = {"customer_not_resolved": col("_known_customer_id").isNull()}
warnings = {
"missing_order_date": col("order_date").isNull(),
"invalid_status": ~col("status").isin(KNOWN_STATUSES) & col("status").isNotNull(),
"future_order_date": col("order_date") > current_date(),
}
return tag_business_rules(joined, critical, warnings).drop("_known_customer_id")
@dp.view(name="silver_orders_clean")
def silver_orders_clean():
return spark.readStream.table("silver_orders_tagged").where("_dq_critical_failed = false")
dp.create_streaming_table("silver_orders_quarantine")
@dp.append_flow(target="silver_orders_quarantine")
def silver_orders_quarantine_flow():
return spark.readStream.table("silver_orders_tagged").where("_dq_critical_failed = true")
dp.create_streaming_table("silver_orders")
dp.create_auto_cdc_flow(
target="silver_orders",
source="silver_orders_clean",
keys=["order_id"],
sequence_by="updated_at",
except_column_list=["_dq_critical_failed", "_dq_critical_failures"],
stored_as_scd_type=2,
)
silver_orders_clean is a @dp.view, not a @dp.table – it’s a pure filter with nothing worth
materializing on its own, existing only so AUTO CDC’s source argument has a named stream of
critical-passed rows to merge from. except_column_list drops the critical-tagging scaffolding
once it’s done its job, while _dq_warnings isn’t listed, so it survives into silver_orders as
an ordinary column on every SCD2 version.
silver_customers: no critical rules, same shape
KNOWN_REGIONS = ["NORTHEAST", "SOUTHEAST", "MIDWEST", "WEST"]
@dp.table(name="silver_customers_tagged", comment="customers tagged with business rule outcomes")
def silver_customers_tagged():
customers = spark.readStream.table("dev.step_right.bronze_customers_valid")
warnings = {
"missing_region": col("region").isNull(),
"invalid_region": ~col("region").isin(KNOWN_REGIONS) & col("region").isNotNull(),
}
return tag_business_rules(customers, critical_checks={}, warning_checks=warnings)
@dp.view(name="silver_customers_clean")
def silver_customers_clean():
return spark.readStream.table("silver_customers_tagged").where("_dq_critical_failed = false")
dp.create_streaming_table("silver_customers")
dp.create_auto_cdc_flow(
target="silver_customers",
source="silver_customers_clean",
keys=["customer_id"],
sequence_by="updated_at",
except_column_list=["_dq_critical_failed", "_dq_critical_failures"],
stored_as_scd_type=2,
)
No _quarantine table is declared for customers – with an empty critical_checks dictionary,
nothing would ever land in it. Declaring a table that can never receive a row is dead weight in the
pipeline graph, not defensive design.
silver_order_items: two critical rules, two referential joins
@dp.table(name="silver_order_items_tagged", comment="order items tagged with business rule outcomes")
def silver_order_items_tagged():
items = spark.readStream.table("dev.step_right.bronze_order_items_valid")
known_products = (
spark.read.table("dev.step_right.bronze_products_valid")
.select(col("product_id").alias("_known_product_id")).dropDuplicates()
)
known_orders = (
spark.read.table("dev.step_right.bronze_orders_valid")
.select(col("order_id").alias("_known_order_id")).dropDuplicates()
)
joined = (
items
.join(known_products, items.product_id == known_products._known_product_id, "left")
.join(known_orders, items.order_id == known_orders._known_order_id, "left")
)
critical = {
"invalid_discount_pct": (col("discount_pct") < 0) | (col("discount_pct") > 100),
"product_not_resolved": col("_known_product_id").isNull(),
}
warnings = {
"missing_quantity": col("quantity").isNull(),
"order_not_resolved": col("_known_order_id").isNull(),
"negative_net_price": (col("unit_price") * (1 - col("discount_pct") / 100)) < 0,
"excessive_quantity": col("quantity") > 10,
}
return tag_business_rules(joined, critical, warnings).drop("_known_product_id", "_known_order_id")
@dp.view(name="silver_order_items_clean")
def silver_order_items_clean():
return spark.readStream.table("silver_order_items_tagged").where("_dq_critical_failed = false")
dp.create_streaming_table("silver_order_items_quarantine")
@dp.append_flow(target="silver_order_items_quarantine")
def silver_order_items_quarantine_flow():
return spark.readStream.table("silver_order_items_tagged").where("_dq_critical_failed = true")
dp.create_streaming_table("silver_order_items")
dp.create_auto_cdc_flow(
target="silver_order_items",
source="silver_order_items_clean",
keys=["order_item_id"],
sequence_by="updated_at",
except_column_list=["_dq_critical_failed", "_dq_critical_failures"],
stored_as_scd_type=2,
)
order_not_resolved staying a warning rather than a critical rule is the one rule in this table
that isn’t as simple as “bad data, quarantine it” – flagged in Lecture 1’s design and worth
restating here in code: a line item whose parent order hasn’t landed yet this micro-batch usually
resolves on the very next run, and quarantining it would just create noisy, self-correcting
“failures” in Section 7’s dashboard.
Verifying SCD2 history and quarantine counts
-- Current state of every order
SELECT * FROM dev.step_right.silver_orders WHERE __END_AT IS NULL;
-- One order's full history
SELECT order_id, status, __START_AT, __END_AT
FROM dev.step_right.silver_orders
WHERE order_id = 'ORD-0000042'
ORDER BY __START_AT;
-- How many rows were quarantined, and why
SELECT explode(_dq_critical_failures) AS rule, COUNT(*) AS row_count
FROM dev.step_right.silver_order_items_quarantine
GROUP BY rule;
-- Report-only warnings on the current, valid data
SELECT explode(_dq_warnings) AS warning, COUNT(*) AS row_count
FROM dev.step_right.silver_orders
WHERE __END_AT IS NULL
GROUP BY warning;
Common mistakes
- Filtering after the merge instead of before it. Applying
WHERE _dq_critical_failed = falseagainstsilver_ordersitself, post-merge, doesn’t prevent a bad row from ever being merged – it just hides it from that one query. The filter has to happen onsilver_orders_clean, upstream ofcreate_auto_cdc_flow. - Skipping
dropDuplicates()on a lookup join, again. The same bronze-layer mistake from Section 2, Lecture 6 fans out row counts here too, onknown_customers,known_products, andknown_ordersalike. - Forgetting
except_column_listexcludes, not includes. Leaving_dq_critical_failedoff the list doesn’t drop it – it does the opposite, carrying a column named for failure into every row of a table meant to represent validated data.
What’s built so far
Three governed tables with full SCD Type 2 history, built only from business-rule-clean data, with
quarantined rows preserved and report-only warnings riding along for anyone who wants to see them.
Lectures 4 and 5 give the file-sourced side of silver – silver_products – the same care, minus
the merge machinery these three tables needed.
| ← Previous: Planning and Designing Silver Pipeline for CDC Sources | Next: Planning and Designing Silver Pipeline for File Sources → | |:—|—:|