Lesson 14 Cost & Governance
Theme:
Lesson 14 · Slide 1

Lesson 14 — Cost & Governance

Who pays, who reads, and how you delete one user’s data.

Data Engineering Module 5 Production
Lesson 14 · Slide 2

Three production concerns

Concern The question it answers Tool
Cost Who is spending the warehouse credits? Query tags + QUERY_HISTORY
Access Which roles can read which tables? Declarative grants YAML
Audit / GDPR Who touched this row, and what does the regulation demand? QUERY_HISTORY + Iceberg deletion

All three concerns centre on the same word: who. Build systems that can answer it in under an hour.

Lesson 14 · Slide 3

Warehouse cost attribution

Warehouse queries cost money — Snowflake charges per credit, BigQuery charges per terabyte scanned, and your cloud bill arrives as one undifferentiated total. Without tagging, you cannot tell whether Analytics or Growth is responsible for the spike on Friday afternoon.

The solution is to tag every query at the boundary: the single Python function that issues SQL against the warehouse. One tag, one place, zero missed queries.

Tag convention

team=<name>;job=<name>;run_id=<uuid>
Lesson 14 · Slide 4

Query tags as the cost-attribution primitive

from warehouse.adapter import query

# Every call carries a tag โ€” three components, semicolon-delimited
query(
    "SELECT count(*) FROM fact_events",
    tag="team=analytics;job=daily_report;run_id=a1b2c3",
)
query(
    "SELECT actor, count(*) FROM events GROUP BY 1",
    tag="team=growth;job=top_actors;run_id=d4e5f6",
)

The warehouse records the tag alongside each query in its history view. A GROUP BY team on that view gives you dollars per team per day.

Lesson 14 · Slide 5

The cost attribution SQL

-- Snowflake: per-team cost over the last 7 days
SELECT
  REGEXP_SUBSTR(query_tag, 'team=([^;]+)', 1, 1, 'e', 1) AS team,
  COUNT(*)                  AS query_count,
  SUM(credits_used_cloud_services) * 3.00 AS estimated_usd
FROM SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY
WHERE start_time >= DATEADD(DAY, -7, CURRENT_TIMESTAMP())
  AND query_tag LIKE 'team=%'
GROUP BY 1
ORDER BY estimated_usd DESC;

Works on Snowflake’s QUERY_HISTORY view. DuckDB has no native credit-cost metadata — the adapter records wall-clock seconds in a local _query_costs table as a fallback.

Lesson 14 · Slide 6

Table-level access controls

analytics_reader:
  description: "Read-only on dim_ and fact_ tables"
  grants:
    - { table: dim_events,  privileges: [SELECT] }
    - { table: fact_events, privileges: [SELECT] }

pii_admin:
  description: "Only role allowed to read PII columns or run GDPR deletes"
  grants:
    - { table: dim_actors,  privileges: [SELECT, DELETE] }
    - { table: events_raw,  privileges: [SELECT, DELETE] }

Grants are declared in governance/grants.yml — the YAML is the source of truth. A deploy script (out of scope) reads it and applies grants via the warehouse’s IAM. The YAML file itself is the audit trail: every change appears in the git diff.

Lesson 14 · Slide 7

Column-level masking for PII

Principle: mask first, grant later. Start with the assumption that every column is PII until you explicitly mark it otherwise.
Lesson 14 · Slide 8

The “who touched this row” audit story

Every warehouse logs query history. Combine three sources to answer any audit question in under an hour:

Three log sources

  1. QUERY_HISTORY — every read and write, with user + query tag
  2. OpenLineage events — every transformation, input/output datasets (from L12)
  3. Git + dbt run metadata — what the code was doing at the time

Join key

All three share time + dataset + user. Join on those three and you have a complete audit trail.

Without all three: a one-hour audit question takes a week.

Lesson 14 · Slide 9

GDPR Article 17 — right to be forgotten

A user submits a deletion request. You have 30 days to comply. The obligation covers every system that holds their data — the data warehouse, Iceberg snapshots, and backups.

Lesson 14 · Slide 10

Iceberg snapshots and hard-delete

Iceberg keeps old data files for time-travel. After a DELETE statement, the rows are gone from the current snapshot — but the old data files still exist on disk, and a time-travel query can reach them.

The two-step hard-delete:

  1. tbl.delete(EqualTo("actor", subject)) — removes matching rows from the current snapshot
  2. tbl.expire_snapshots() — physically removes old data files that still contained those rows

Only after both steps is the data genuinely gone.

Lesson 14 · Slide 11

The deletion code path

def delete_actor(actor: str) -> dict[str, int]:
    """Hard-delete all rows for actor across every Iceberg table."""
    catalog = load_catalog("gharchive_local")
    deleted = {}
    for tname in ("events_raw", "dim_actors", "fact_events"):
        tbl = catalog.load_table(tname)
        before = tbl.scan().to_pandas().shape[0]
        tbl.delete(EqualTo("actor", actor))      # step 1
        after = tbl.scan().to_pandas().shape[0]
        deleted[tname] = before - after
        tbl.expire_snapshots()                    # step 2
    return deleted

Idempotent — re-running after a successful deletion is a no-op.

Lesson 14 · Slide 12

Audit log of deletions

Every GDPR delete must itself be logged (Article 17 requires you to demonstrate compliance). The deletion log is an append-only file — and it is the one file you do NOT delete in response to a subsequent request. It is the regulator’s audit trail.

{
  "ts": "2026-05-26T12:00:00Z",
  "action": "gdpr_delete",
  "subject": "target-user",
  "tables_affected": ["events_raw", "dim_actors", "fact_events"],
  "rows_deleted": {"events_raw": 2, "dim_actors": 0, "fact_events": 0},
  "operator": "lab-14-step-7"
}
Lesson 14 · Slide 13

What we’ll do in lab

All steps run locally against DuckDB + Iceberg; no cloud account required.
Lesson 14 · Slide 14

Schema diff in the sample pipeline

 sample-pipeline/
   extract/
   transform/
   quality/
   airflow/
   dbt/
   iceberg/
   warehouse/
+    adapter.py         # tag kwarg added for cost attribution
   observability/
   schema_evolution/
+  governance/          # NEW โ€” L14
+    __init__.py
+    cost_attribution.py
+    grants.yml
+    gdpr/
+      __init__.py
+      right_to_be_forgotten.py

Final state matches the course spec’s ยง3 pipeline inventory exactly.

Lesson 14 · Slide 15

Recap & course complete

Course complete. The sample-pipeline now covers the full production-grade ETL lifecycle: extract → transform → quality → orchestration → SQL → dbt → DuckDB → Iceberg → warehouse adapters → observability → schema governance → cost & GDPR. Streaming is the natural next step — see the sibling Cloud Infrastructure & DevOps course currently in development.
1 / ?