Developing Quarantine Pattern in Bronze Layer
Estimated read: 13 min
Lecture 5 designed tag-don’t-drop quality tagging and a valid/quarantine split, all downstream of untouched raw bronze. This lecture implements it – a reusable tagging helper, one fully worked example with a real referential check, and the fan-out pattern that actually splits valid rows from quarantined ones.
A reusable tagging helper
# transformations/dq_helpers.py
from pyspark.sql.functions import when, lit, array, array_except, size, col
def tag_quality(df, checks: dict):
"""
checks: {rule_name: boolean column that is TRUE when the row FAILS that rule}
Adds _dq_failed_rules (array of failed rule names) and _dq_valid (bool).
"""
failed_flags = array(*[
when(condition, lit(name)) for name, condition in checks.items()
])
return (
df
.withColumn("_dq_failed_rules", array_except(failed_flags, array(lit(None).cast("string"))))
.withColumn("_dq_valid", size(col("_dq_failed_rules")) == 0)
)
Every quality-tagged table in this pipeline calls tag_quality with a dictionary of named
checks – the table below only needs to define what to check, not how tagging itself works.
Worked example: bronze_orders with a referential check
bronze_orders needs one check tag_quality can’t do alone: whether customer_id actually
exists in bronze_customers. That’s a stream-static join – the CDC stream of orders joined
against a batch snapshot of known customer IDs – a standard, supported Structured Streaming
pattern for exactly this “does this foreign key resolve” question:
# transformations/bronze_quality.py
import dlt as dp
from pyspark.sql.functions import col
from dq_helpers import tag_quality
@dp.table(name="bronze_orders_tagged", comment="bronze_orders with structural DQ tags")
def bronze_orders_tagged():
orders = spark.readStream.table("dev.step_right.bronze_orders")
known_customers = (
spark.read.table("dev.step_right.bronze_customers")
.select(col("customer_id").alias("_known_customer_id"))
.dropDuplicates()
)
joined = orders.join(
known_customers, orders.customer_id == known_customers._known_customer_id, "left"
)
checks = {
"missing_order_id": col("order_id").isNull(),
"unknown_customer_id": col("_known_customer_id").isNull() & col("customer_id").isNotNull(),
}
return tag_quality(joined, checks).drop("_known_customer_id")
The left join is what makes unknown_customer_id answerable: if customer_id doesn’t match any
row in known_customers, _known_customer_id comes back null after the join, and the check flags
it – exactly the ~1% of orders Section 1, Lecture 5’s generator deliberately pointed at
CUST-999999, a customer ID that was never generated.
The fan-out: splitting valid from quarantined
SDP’s append flow pattern is what actually performs the split – two independent streaming writes into two different target tables, both reading from the same tagged source:
dp.create_streaming_table("bronze_orders_valid")
dp.create_streaming_table("bronze_orders_quarantine")
@dp.append_flow(target="bronze_orders_valid")
def bronze_orders_valid_flow():
return spark.readStream.table("bronze_orders_tagged").where("_dq_valid = true")
@dp.append_flow(target="bronze_orders_quarantine")
def bronze_orders_quarantine_flow():
return spark.readStream.table("bronze_orders_tagged").where("_dq_valid = false")
bronze_orders_valid is what Section 3’s silver pipeline reads from. bronze_orders_quarantine
keeps every rejected row, with _dq_failed_rules explaining exactly why, for Section 7’s
monitoring dashboard.
Generating the simpler tables from the same helper
bronze_customers and bronze_products only need null checks, no joins – a good case for the
same factory-function approach Lecture 4 used for the file sources:
SIMPLE_QUALITY_TABLES = {
"bronze_customers": {"missing_customer_id": col("customer_id").isNull()},
"bronze_products": {
"missing_product_id": col("product_id").isNull(),
"missing_list_price": col("list_price").isNull(),
},
}
def make_quality_tables(source_name: str, checks: dict):
@dp.table(name=f"{source_name}_tagged", comment=f"{source_name} with structural DQ tags")
def _tagged():
return tag_quality(spark.readStream.table(f"dev.step_right.{source_name}"), checks)
dp.create_streaming_table(f"{source_name}_valid")
dp.create_streaming_table(f"{source_name}_quarantine")
@dp.append_flow(target=f"{source_name}_valid")
def _valid_flow():
return spark.readStream.table(f"{source_name}_tagged").where("_dq_valid = true")
@dp.append_flow(target=f"{source_name}_quarantine")
def _quarantine_flow():
return spark.readStream.table(f"{source_name}_tagged").where("_dq_valid = false")
for source_name, checks in SIMPLE_QUALITY_TABLES.items():
make_quality_tables(source_name, checks)
bronze_order_items follows bronze_orders’ pattern exactly, just with two referential joins
(order_id against bronze_orders, product_id against bronze_products) instead of one, plus
the value checks from Lecture 5’s design table (unit_price >= 0, quantity > 0) – the same
tag_quality helper and append-flow split, only the checks dictionary and join logic grow.
Verifying quarantine counts
SELECT COUNT(*) AS total,
SUM(CASE WHEN size(_dq_failed_rules) = 0 THEN 1 ELSE 0 END) AS valid,
SUM(CASE WHEN size(_dq_failed_rules) > 0 THEN 1 ELSE 0 END) AS quarantined
FROM dev.step_right.bronze_orders_tagged;
SELECT explode(_dq_failed_rules) AS rule, COUNT(*) AS row_count
FROM dev.step_right.bronze_orders_quarantine
GROUP BY rule
ORDER BY row_count DESC;
Against batch zero, bronze_orders_quarantine should land close to Section 1, Lecture 5’s ~1%
unknown_customer_id injection rate, and bronze_order_items_quarantine close to its ~2% orphaned
product_id rate plus a much smaller share of negative-unit_price rows. Numbers well outside
those ranges usually mean the join key or the generator’s random seed changed somewhere between
lectures – worth checking before assuming the tagging logic itself is wrong.
Common mistakes
- Joining on the wrong side. A
rightorinnerjoin instead ofleftin the referential check silently drops the very rows you’re trying to flag – an inner join betweenordersandknown_customersnever produces a null_known_customer_idto detect, because unmatched rows are removed from the result before the check ever runs. - Forgetting
dropDuplicates()on the lookup side. A batch read ofbronze_customerswith a duplicatecustomer_id(from CDC replaying an update) fans the join out, inflating row counts in the tagged table without changing which rows are actually valid. - Checking
_dq_validbeforetag_qualityfinishes.array_exceptneeds its second argument cast to match the array’s element type exactly (array(lit(None).cast("string"))) – omitting the cast produces a type-mismatch error that has nothing to do with the actual quality logic.
What Section 2 leaves ready for Section 3
Seven raw bronze tables, untouched. Seven tagged tables explaining exactly why each row passed or
failed. Seven pairs of valid/quarantine tables, with _valid as the only thing silver is allowed
to read. Section 3 picks up from bronze_orders_valid, bronze_customers_valid, and
bronze_order_items_valid to build AUTO CDC’s SCD Type 2 history – structurally sound data,
before a single business rule has been applied to it.
| ← Previous: Design Source Data Quality Monitoring in Bronze Layer | Next: StepRight - Transformation Layers → | |:—|—:|