13Operations
This route decides who gets woken up, what they read once they are, and what they may do without asking anybody. The load-bearing artefact is not an incident process document — it is one line per pipeline saying whether a re-run is safe, written months before anyone needs it at 03:00.
Runbooks
A runbook is not documentation. It is a script for a tired person with a phone, and it is only correct if someone who was actually woken up wrote it.
#One page, written by whoever gets woken
RUNBOOK silver_sales_order owner: grp_dwh_engineers
alert "expectation valid_amount: failed_records > 0"
page yes, 24/7 (wrong numbers, not late numbers)
1 WHAT BROKE
The Lakeflow pipeline dropped order lines violating business
rule 5: amount_eur > 0 for non-cancelled lines. Usual cause:
silver.fx_rate has no CHF row for the order date, so
amount_eur comes out NULL.
2 FIRST QUERY (SQL warehouse "ops", catalog dwh_prod)
SELECT l.order_ts::DATE AS d, l.currency, COUNT(*) AS lines
FROM dwh_prod.silver.sales_order_line l
LEFT JOIN dwh_prod.silver.fx_rate r
ON r.currency = l.currency
AND r.rate_date = l.order_ts::DATE
WHERE r.currency IS NULL
AND l.order_ts >= current_date() - INTERVAL 3 DAYS
GROUP BY 1, 2 ORDER BY 1;
3 DECIDE
rows returned -> missing FX. Load the rate, then re-run.
zero rows -> not FX. Escalate, do NOT re-run.
4 RE-RUN? safe blind. Full refresh: NO — see re-run table.
5 ESCALATE after 45 min to grp_dwh_engineers rota #2.
6 TELL #dwh-status, before you start, not after you finish.Doing it
- One runbook per alert. A single pipeline can fail three ways that need three different first queries.
- Put the runbook link in the alert payload. A runbook found by searching a wiki is a runbook read after the incident.
- Start with a copy-pasteable query returning rows or zero rows, and say what each outcome means.
- Give the escalation a timer in minutes and a name. 'Escalate if needed' means nobody escalates.
Defaults
- One page, six blocks: what broke, first query, decide, re-run answer, escalate, tell.
- Runbook link in the alert payload; runbook file in the repo beside the pipeline.
- First step is always a query with a stated interpretation, never 'check the logs'.
- Written by the responder, reviewed by the pipeline's author — that direction, not the reverse.
Gotchas
- A step that says 'check the logs'. Three logs could be meant — pipeline event log, job run output, driver log — and at 03:00 the responder opens the wrong one first. Name the log and the filter.
- Runbooks in a wiki space nobody has bookmarked. The symptom is an incident handled entirely from memory and Slack, followed by 'we have a runbook for that' the next morning.
- One runbook per pipeline. It becomes a decision tree, the responder reads the wrong branch, and the first query answers a question they did not have.
- A runbook never executed by anyone but its author. It reads complete and is missing a catalog name or a permission the rota does not hold — which surfaces as access-denied, mid-incident.
#The re-run question
| Job / pipeline | Write pattern | Re-run blind? | If you do it anyway |
|---|---|---|---|
| bronze.shop_order | Autoloader append, checkpointed | yes | Nothing — the checkpoint skips seen files. Full refresh is a separate question and the answer is no: it re-lists the Volume and duplicates every row on an append-only bronze. |
| bronze.sap_kna1 | daily full snapshot, append-only | no — check first | A second batch lands for the same logical day. Snapshot comparison then diffs today against today, finds nothing, and writes no SCD2 version for customers that did change. |
| silver.customer | SCD2 merge | yes, if as_of is a parameter | With current_timestamp() the re-run closes the interval it opened four minutes ago and opens another. Two versions, one business change, _tech_is_current still true on exactly one row. |
| silver.sales_order_line | SCD1 full overwrite | yes | Nothing. Overwrite is idempotent by construction — the only row whose answer needs no caveat. |
| gold.fct_order_line | MERGE on order_line_sk | yes, if silver is complete | Against a half-written silver it upserts a partial day, and gold disagrees with silver with no error anywhere. Duplicate order_line_sk aborts the MERGE on multiple source matches — the loud, good case. |
| gold.agg_revenue_month | INSERT INTO … REPLACE WHERE month = :month | yes, if REPLACE WHERE is on the statement | Written as a bare INSERT OVERWRITE it replaces the WHOLE table with the one month in the SELECT — every other month gone in a single commit, no error. The table is liquid-clustered and unpartitioned, so partitionOverwriteMode=dynamic does not scope it either; REPLACE WHERE is the only mechanism that does. Scoped too widely, it re-states closed months from whatever gold.fct_order_line holds now, moving last quarter's published revenue after the quarter closed. |
def run(spark, catalog: str, as_of: str) -> int:
"""Rebuild silver.customer as SCD2. Returns rows in the table after.
as_of is a JOB PARAMETER, never current_timestamp(). Re-running the
same logical day must produce identical intervals; a wall clock read
inside the job turns every re-run into a new version.
"""
target = f"{catalog}.silver.customer"
incoming = clean_customer(spark.read.table(f"{catalog}.bronze.sap_kna1"))
new_state = apply_scd2( # -> DataFrame, full new state
current=spark.read.table(target),
incoming=incoming,
keys=["customer_id"],
as_of=as_of,
)
# Delta refuses to overwrite a table that is also being read from, and
# new_state reads target. Stage, then swap — INSERT OVERWRITE keeps the
# table's comment, clustering and grants; CREATE OR REPLACE loses them.
stage = f"{catalog}.silver.stg_customer_scd2"
new_state.write.mode("overwrite").saveAsTable(stage)
spark.sql(f"INSERT OVERWRITE TABLE {target} SELECT * FROM {stage}")
spark.sql(f"DROP TABLE {stage}")
return spark.table(target).count()SELECT _tech_batch_id,
COUNT(*) AS rows_landed,
MAX(_tech_loaded_at) AS loaded_at
FROM dwh_prod.bronze.sap_kna1
WHERE _tech_loaded_at >= current_date()
GROUP BY _tech_batch_id
ORDER BY loaded_at DESC;
-- one batch id -> extract landed once, re-run is safe
-- two batch ids -> already re-run. Do NOT repeat; resume at silver.Doing it
- Add the re-run row in the same pull request as the job. Retro-fitting it means re-deriving intent from someone else's code.
- Inject every clock — as_of, run_date, batch_id are parameters, never read from the wall clock inside a transform.
- Give each yes-if row its check query verbatim in the runbook. A condition that takes thinking to verify gets skipped.
- Prove the 'yes' rows in CI: run twice, assert identical — see the warehouse-invariants route.
Defaults
- Three answers only: yes, yes-if-
, no-with-procedure. No prose. - The table lives in the repo and is reviewed with the code, not in an operations wiki.
- Re-run and full refresh answered separately for every pipeline.
- Idempotency proven by a test, never asserted in a table.
Gotchas
- current_timestamp() inside an SCD2 merge. Every re-run opens a version for rows that did not change, _tech_is_current stays correct, nothing asserts. You find it when someone asks how often a customer changed segment and the answer is 'nine times, all on 14.03.2026'.
- Treating full refresh as a stronger re-run. On a @dp.table streaming table it clears checkpoint state and reprocesses from source — duplicating append-only bronze, and silently losing rows where source retention is shorter than the window.
- Marking a job safe because the last three re-runs were fine. Safety is a property of the write pattern, not of the sample: an overwrite is safe, an append is not, however often it has been repeated without incident.
- Re-running a downstream job while the upstream is half-written. gold.fct_order_line merges a partial silver day, nothing errors, and gold and silver now disagree behind a green run history.
- Believing a WHERE clause scopes an INSERT OVERWRITE. It does not: the predicate filters the SELECT, the overwrite still replaces the whole table, so one re-run of gold.agg_revenue_month leaves exactly one month in it. On a liquid-clustered table there are no partitions for partitionOverwriteMode=dynamic to spare either. You notice when a year-on-year chart renders a single bar, and the fix is a backfill, not a rollback, if VACUUM already ran.
Incidents
The warehouse-specific part of incident response is the severity ladder: wrong numbers outrank late numbers, and most teams have it backwards because only the late ones page.
#Severity, blast radius, and the first ten minutes
- S1 — published numbers are wrong. gold.fct_order_line or gold.mv_sales is serving revenue that violates a business rule, and a dashboard has already shown it. Page at any hour.
- S2 — data is stale past its SLA. gold.fct_order_line has a 26-hour freshness SLA and has breached it. Page in business hours: the numbers are right, just old, and the timestamp is visible.
- S3 — a job failed, the data is correct and inside SLA. A retry handled it, or the next run will. Ticket, no page.
-- 1. pause the downstream refresh FIRST. Diagnosing while a scheduled
-- BI refresh republishes the wrong number turns a one-hour incident
-- into a one-day one.
-- 2. how bad, and since when
DESCRIBE HISTORY dwh_prod.gold.fct_order_line LIMIT 10;
SELECT COUNT(*) AS bad_lines,
MIN(order_ts) AS first_bad,
MAX(order_ts) AS last_bad
FROM dwh_prod.gold.fct_order_line
WHERE status <> 'CANCELLED'
AND (amount_eur IS NULL OR amount_eur <= 0); -- business rule 5
-- 3. blast radius: who read it while it was wrong. Filter on event_date,
-- not event_time: the table is laid out by date, and event_time scans
-- the whole of it — a full scan you are paying for mid-incident.
SELECT DISTINCT entity_type, entity_id, target_table_full_name
FROM system.access.table_lineage
WHERE source_table_full_name = 'dwh_prod.gold.fct_order_line'
AND event_date >= current_date() - INTERVAL 7 DAYS;
-- a NULL target is a notebook, dashboard or ad-hoc read. Still a consumer.
-- 4. if a bad version was published, roll back — do not patch
RESTORE TABLE dwh_prod.gold.fct_order_line TO VERSION AS OF 412;Doing it
- Define the three severities against real tables and real SLAs, and write down which one pages at 03:00.
- Split the roles: one on the keyboard, one running the incident, one on comms. Below three people the lead is still a role — held by whoever is not typing.
- Pause downstream refresh as move one, and record the pause so it gets undone.
- Prefer RESTORE to a corrective UPDATE. A rollback is one auditable operation; a patch is a new transformation written under time pressure with no test.
Defaults
- Severity defined by consequence to the data, not by which component failed.
- Pause, measure, diagnose — in that order, every time.
- Rollback over patch; a corrective UPDATE gets the same review as any other transformation.
- Blast radius from lineage treated as a lower bound, never as a complete list.
Gotchas
- Diagnosing before pausing the refresh. The dashboard republishes the wrong number every fifteen minutes while you work, so the population who need telling afterwards grows for the whole duration of the incident.
- Debugging in the incident channel. Stack traces and the narrative compete for one scroll, the business reader stops following, and starts asking the incident lead directly — which removes the incident lead.
- Nobody declares the incident over. The paused refresh stays paused, the dashboard is stale for a week, and the next complaint is about the fix rather than the fault.
- Assuming an empty lineage result means nothing consumed the table. Lineage is emitted only where it can be inferred; a downstream extract nobody registered will not appear, and you will not notify its owner.
- verifiedLineage system tables keep a rolling 1-year window. Catalog Explorer and the lineage API retain lineage captured after 01.09.2024 indefinitely.Stand 29.07.2026
- verifiedPredictive optimization runs OPTIMIZE, VACUUM and ANALYZE automatically on Unity Catalog managed tables. It is on by default for accounts created on or after 11.11.2024.Stand 29.07.2026
#Game day, week 13
dwh_test, with real code and real alert routing. You break things on purpose and do not touch the keyboard. Week 13 because it is a month past the point where the team's contribution overtakes yours — late enough that they built this, early enough that you are still here to fix what the day exposes.- The SAP extract never arrives — no file at 04:00 for bronze.sap_kna1. Does anything alert, or does the pipeline succeed on yesterday's data?
- The extract arrives twice: two batch ids for one logical day. Does anyone notice the missing SCD2 versions?
- silver.fx_rate has no CHF row for one day, so amount_eur goes NULL for Swiss lines and breaks business rule 5. Which fires — the expectation, the freshness check, or nobody?
- SAP adds a column to VBAP. Does the schema-drift check catch it, and does the pipeline keep running?
- The gold MERGE is killed mid-run. Is gold.fct_order_line consistent, and does the runbook's re-run answer hold?
- The pipeline's author is 'on holiday' and may not speak for three hours. This is the scenario that tests the runbooks.
Doing it
- Three hours, dwh_test, alerts routed to the real rota. A muted workspace teaches nothing about routing.
- Seed the failures from a fixtures script so the day is reproducible and can be repeated after handover.
- Time each scenario: time to alert, time to correct diagnosis, time to recovery. Those three numbers are the deliverable.
- Every gap found becomes a pull request that week, with a named owner, before anyone goes home on Friday.
Defaults
- Three hours, six scenarios, dwh_test, real alerts, consultant silent.
- Measure time-to-alert, time-to-diagnosis, time-to-recovery per scenario.
- Seeded from a script, so it repeats rather than being an event.
- One scenario always removes the author of the thing being broken.
Gotchas
- Running it where alerting is disabled 'to avoid noise'. You then measure diagnosis and learn nothing about routing — and routing is what is broken in most teams, because it was configured once and never received a real page.
- Announcing the scenarios in advance. The team pre-reads the runbooks, every timer looks good, and the exercise certifies a state that will not exist at 03:00.
- Choosing scenarios everyone already handles. A game day that finds nothing was designed to find nothing; a debrief with no actions means the scenarios were too kind.
- Doing it in week 24. Whatever it exposes is now a handover risk instead of a coaching opportunity, with no time to fix it with the team rather than for them.
#The note lands in the runbook, or it never lands
2 FIRST QUERY (SQL warehouse "ops", catalog dwh_prod)
+ -- the rota needs SELECT on silver.fx_rate; PERMISSION_DENIED
+ -- here means you are not in grp_dwh_engineers.
SELECT l.order_ts::DATE AS d, l.currency, COUNT(*) AS lines
...
3 DECIDE
rows returned -> missing FX. Load the rate, then re-run.
zero rows -> not FX. Escalate, do NOT re-run.
+ ALL currencies missing for a date -> the ECB CSV was empty,
+ not late. Loading it again will not help. Escalate at once.
seen: 03.07.2026, 04:12-05:35. Detected by the expectation, not by
the freshness alert — freshness would have fired at 06:00.Doing it
- Edit the runbook in the same PR as the fix. Reviewers reject a fix whose incident revealed a gap and whose runbook is untouched.
- Write it inside 48 hours, while the wrong turns are still remembered — the wrong turns are the valuable part.
- Record the detection path: which alert fired, which should have, and how long before a human saw it.
- Delete steps that turned out to be unnecessary. A runbook that only grows is unusable within a year.
Defaults
- Fix and runbook edit in one pull request, always.
- Written within 48 hours by the person who was paged.
- Detection path recorded: what fired, what should have, and the delay.
- Runbooks lose lines as well as gain them.
Gotchas
- Post-mortems in a separate document space. Written once, read never, and the next responder rediscovers the same missing permission from scratch — the incident repeats in exactly the same shape.
- Action items with an owner and no date, or a date and no owner. Both mean the item does not exist, and both look like follow-through in a retro.
- Writing the note a week later. The reconstructed sequence is tidier than the real one, so the runbook documents a path that does not work in the order given.
- Adding and never removing. Past two pages responders start skimming, the critical line drops below the fold, and the runbook now contains the answer while nobody reads it.
Ownership at handover
Two kinds of ownership, both required, neither substitutable: a group owns the object so it survives a leaver, a named person carries the pager so the alert reaches somebody.
#Named owners, and getting off the rota
| Asset | UC owner (group) | Rota (named) | Runbooks (one per alert) | Last exercised |
|---|---|---|---|---|
| dwh_prod.gold.fct_order_line | grp_dwh_engineers | dwh-oncall — pool of 4 | rb_fct_order_line_* — 2 | 12.06.2026 game day |
| Lakeflow pipeline silver_sales_order | grp_dwh_engineers | dwh-oncall — pool of 4 | rb_silver_sales_order_* — 3 | 03.07.2026 incident |
| Job silver_customer | grp_dwh_engineers | dwh-oncall — pool of 4 | rb_silver_customer_* — 1 | never |
| dwh_prod.gold.mv_sales | grp_dwh_analytics | bi-oncall — pool of 2 | rb_mv_sales_* — 1 | never |
data-team@ inbox with 400 unread messages. Alerts keep firing and keep being delivered, so every dashboard is green: the alerting works perfectly and lands nowhere.Doing it
- Build the ownership sheet in week 20 and review it with names in the room, not with roles.
- Hold the rota exit date agreed at kick-off, and make it four weeks before the end rather than the last day.
- Fire a real test alert down every destination at handover and confirm a human replies. Configured is not delivered.
- Grep the workspace for the consultant's address before the last week, and expect to find it in a job notification nobody remembers creating.
Defaults
- Groups own UC objects; named people carry the pager. Both, always.
- A pool of at least four, so the weekly pair of names can always be filled.
- Consultant off the alert rota four weeks before the end, and reachable for all four.
- Sheet reviewed quarterly; 'last exercised' is the column that shows rot.
Gotchas
- The consultant's address left in a job notification. Alerts deliver successfully for months after the contract ends, so nothing looks broken — and the discovery is a freshness breach that had been firing daily since March.
- A pool of two. It holds until one holiday overlaps one illness, and then the weekly pair cannot be filled, so the page goes to whoever is online — which at 03:00 is nobody.
- UC objects owned by an individual. They leave, nobody can grant on the table, and the fix needs a metastore admin at exactly the moment the grant was urgent.
- The ownership sheet living in the handover deck. It is never edited, so within a quarter it names two people who changed team and one who left, and the rota it describes has not existed for months.
Straight to the documentation
The generic platform material lives in the official docs and is not restated here. These are the pages worth having open while you work through this route.
- Repair an unsuccessful job run — repair re-runs only the failed tasks — the mechanism every 'yes' in the re-run table quietly assumes
- Add notifications to a job — the field where alert routing is either pointed at a rota or quietly left on one person's address
- RESTORE (Delta Lake) — the rollback a runbook should prefer over a corrective UPDATE — read the retention constraint before promising it
- Work with Delta Lake table history — DESCRIBE HISTORY answers 'which run wrote the wrong rows, and when' in the first five minutes
- Jobs system tables reference — run and failure history as SQL, which is what a post-incident note needs and the UI will not give you