Lab 14 — Tag, Grant, and Forget
Lesson 14 · Data Engineering with Python · ~45 min
By the end of this lab you will have: three cost-tagged queries visible in the local
_query_costs table, the access_grants.yaml YAML reviewed for
correctness, and one synthetic GDPR deletion run end-to-end against local Iceberg tables
with an audit log entry written.
Prerequisites
- Lesson 13 lab completed (the
schema_evolution/tree exists). - Sample-pipeline virtual environment activated with
pyiceberg[duckdb]:cd data-engineering/sample-pipeline pip install 'pyiceberg[duckdb]' duckdb pandas pyyaml - Some events loaded into the local Iceberg catalog (from the Lesson 10 lab). If you skipped L10, see the troubleshooting section below.
Step 1 — Tag three queries
Run three queries through the adapter, each with a different team tag. The adapter records
(ts, tag, elapsed_s) in _query_costs automatically.
python -c "
from warehouse.adapter import query
query('SELECT count(*) FROM events', tag='team=analytics;job=row_count')
query('SELECT actor, count(*) FROM events GROUP BY 1', tag='team=growth;job=top_actors')
query('SELECT created_at FROM events LIMIT 1', tag='team=eng;job=probe')
print('Three tagged queries executed.')
"
query() ran the SQL and then
inserted a row into _query_costs with the tag and the wall-clock duration.
On Snowflake the same tag string would have been sent as
ALTER SESSION SET QUERY_TAG = '...' before the SQL.
Step 2 — View the cost attribution (DuckDB fallback)
Aggregate _query_costs to see per-team totals:
python -c "
from warehouse.adapter import query
df = query(
\"SELECT regexp_extract(tag, 'team=([^;]+)', 1) AS team, \"
\"count(*) AS queries, round(sum(elapsed_s), 4) AS total_s \"
\"FROM _query_costs GROUP BY 1 ORDER BY total_s DESC\"
)
print(df.to_string(index=False))
"
Expected: 3 rows, one per team (analytics, growth, eng).
governance/cost_attribution.py and copy the SNOWFLAKE_COST_SQL
constant into the Snowflake worksheet UI. Run it. You should see the same shape of output
but with credit-dollar columns instead of elapsed seconds.
Step 3 — Review the grants YAML
Open governance/grants.yml and answer these questions before continuing:
- Which role(s) can read
dim_actors.email? - Which role(s) can write to
fact_events? - Which role(s) can run a GDPR deletion against
events_raw?
Verify the file parses correctly:
python -c "
import yaml
data = yaml.safe_load(open('governance/grants.yml'))
print('Roles:', list(data['roles']))
print('Masking policies:', [p['column'] for p in data['masking_policies']])
"
Expected output:
Roles: ['analytics_reader', 'pipeline_writer', 'pii_admin']
Masking policies: ['dim_actors.email', 'dim_actors.ip_address']
Answers: only pii_admin can read dim_actors.email;
pipeline_writer can write to fact_events; only pii_admin
can delete from events_raw.
Step 4 — Set up the GDPR demo data
Check whether a test actor already exists in the local events_raw table:
python -c "
import duckdb
con = duckdb.connect('warehouse/warehouse.duckdb')
rows = con.execute(\"SELECT count(*) FROM events_raw WHERE actor='target-user'\").fetchall()
print('Existing rows for target-user:', rows[0][0])
"
If the count is 0, insert a few synthetic rows for the demo:
python -c "
import duckdb
con = duckdb.connect('warehouse/warehouse.duckdb')
con.execute(\"\"\"
INSERT INTO events_raw (id, actor, repo, created_at)
VALUES
(1001, 'target-user', 'demo/repo', current_timestamp),
(1002, 'target-user', 'demo/repo', current_timestamp)
\"\"\")
con.commit()
print('Inserted 2 rows for target-user.')
"
Step 5 — Run the GDPR deletion
Use the governance module directly:
python -c "
from governance.gdpr.right_to_be_forgotten import delete_actor
result = delete_actor('target-user')
for tname, n in result.items():
print(f'deleted {n} rows from {tname}')
"
Expected output:
deleted 2 rows from events_raw
deleted 0 rows from dim_actors
deleted 0 rows from fact_events
expire_snapshots() on each table after the deletion.
This step physically removes the old Iceberg data files that still contained the rows in
previous snapshots. Without it, a time-travel query could recover the data.
Step 6 — Verify the rows are gone
python -c "
import duckdb
con = duckdb.connect('warehouse/warehouse.duckdb')
rows = con.execute(\"SELECT count(*) FROM events_raw WHERE actor='target-user'\").fetchall()
print('Remaining rows:', rows[0][0])
"
Expected: Remaining rows: 0.
Step 7 — Audit-log the deletion
Append a JSON-lines entry to the deletion log. This file is the regulator’s audit trail and must never be deleted in response to a subsequent erasure request.
python -c "
import json, datetime, os
entry = {
'ts': datetime.datetime.now(datetime.timezone.utc).isoformat(),
'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',
}
os.makedirs('governance/gdpr', exist_ok=True)
with open('governance/gdpr/deletion_log.jsonl', 'a') as f:
f.write(json.dumps(entry) + '\n')
print('Logged:', entry['action'], 'for subject:', entry['subject'])
"
Confirm the log was written:
python -c "
import json
for line in open('governance/gdpr/deletion_log.jsonl'):
entry = json.loads(line)
print(entry['ts'], entry['action'], entry['subject'])
"
What you should leave with
- Three tagged queries’ cost visible in
_query_costs(per-team totals) - The
grants.ymlstructure understood: which role reads PII, which role writes facts - One actor fully deleted from the Iceberg tables including snapshot expiration
- A deletion audit log entry written to
governance/gdpr/deletion_log.jsonl
Troubleshooting
-
pyiceberg.exceptions.NoSuchTableError— the local Iceberg catalog has not been initialised. Run the Lesson 10 lab first to create thedefault.eventstable and thewarehouse/catalog.dbmetadata. -
expire_snapshots()raises a permissions error — the Iceberg metadata path is not writable by the current user. Check thatwarehouse/is owned by your user and has write permissions:chmod -R u+w data-engineering/sample-pipeline/warehouse/. -
Cost attribution returns 0 rows on DuckDB — the adapter only
records cost data when queries are issued through
warehouse.adapter.query(). Queries run directly viaduckdb.execute()bypass the recorder entirely. Make sure you ran the Step 1 commands above before querying_query_costs. -
The
events_rawtable does not exist — the DuckDB warehouse has not been populated. Run the Lesson 11 materialization step (python -m warehouse.duckdb_local) to create the table from the local Iceberg data.