Skip to content

Commit b9878d0

Browse files
fix(snowflake): SHOW ... LIKE instead of INFORMATION_SCHEMA for least-privilege roles
Eric's testing surfaced that least-privilege Snowflake roles (e.g. DAGSTER_RUNNER with USAGE on the database but not full ACCOUNTADMIN) can hit "does not exist or not authorized" errors on INFORMATION_SCHEMA views even when SHOW commands work fine on the same objects. This is a documented Snowflake quirk affecting new-feature INFORMATION_SCHEMA views specifically. Fix: in the materialize-time metadata-read blocks (after the actual work — REFRESH / EXECUTE / ALTER — has succeeded), swap INFORMATION_SCHEMA queries to SHOW ... LIKE '<name>' IN SCHEMA <db>.<schema>. Wrap each in try/except so the action still wins even if the SHOW also fails. Updated in snowflake_workspace/component.py: - _dynamic_table_asset: INFORMATION_SCHEMA.DYNAMIC_TABLES → SHOW DYNAMIC TABLES LIKE (refresh_mode, scheduling_state, target_lag, rows, bytes) - _task_asset: added SHOW TASKS LIKE (state, schedule, warehouse, owner) + TASK_HISTORY wrapped in try/except - _snowpipe_asset: added SHOW PIPES LIKE (owner, notification_channel, definition) + SYSTEM$PIPE_STATUS wrapped + COPY_HISTORY wrapped - _mv_asset: INFORMATION_SCHEMA.TABLES → SHOW MATERIALIZED VIEWS LIKE (rows, bytes, cluster_by, behind_by, invalid, owner) - _external_table_asset: INFORMATION_SCHEMA.TABLES → SHOW EXTERNAL TABLES LIKE (rows, location, file_format_name, last_refreshed_on) - _alert_asset: added SHOW ALERTS LIKE (state, schedule) + ALERT_HISTORY wrapped in try/except - observation sensor: DYNAMIC_TABLES → SHOW DYNAMIC TABLES LIKE Updated in Wave 3 single-entity components: - snowflake_dynamic_table_refresh_asset: same DYNAMIC_TABLES → SHOW pattern - snowflake_task_execute_asset: added SHOW TASKS LIKE + TASK_HISTORY wrapped in try/except - snowflake_task_completion_sensor: improved skip_reason error message to point at MONITOR grant as the likely fix Rationale per Eric's diagnosis: "SHOW only requires USAGE on the schema + any privilege on the DT, which is what least-privilege roles typically have. INFORMATION_SCHEMA.DYNAMIC_TABLES, by contrast, can be invisible to scoped roles on some Snowflake accounts (account-level policy) even with USAGE on the database — Snowflake reports the view as 'does not exist or not authorized'." The action (REFRESH / EXECUTE / ALTER) is what the customer actually wants done; metadata enrichment is best-effort. Even if both SHOW and INFORMATION_SCHEMA reads fail, the asset still materializes with the basic metadata (name, database, schema) and a clear log warning about the missing enrichment. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 601beef commit b9878d0

5 files changed

Lines changed: 379 additions & 7244 deletions

File tree

assets/data-warehouse/snowflake_dynamic_table_refresh_asset/component.py

Lines changed: 29 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -120,34 +120,41 @@ def snowflake_dynamic_table_refresh_asset(context: AssetExecutionContext) -> Mat
120120
context.log.info(f"ALTER DYNAMIC TABLE {fqn} REFRESH")
121121
cursor.execute(f"ALTER DYNAMIC TABLE {fqn} REFRESH")
122122

123-
# Pull current state from INFORMATION_SCHEMA.DYNAMIC_TABLES.
124-
cursor.execute(f"""
125-
SELECT name, scheduling_state, last_refresh_status,
126-
target_lag, refresh_mode,
127-
last_successful_refresh_time
128-
FROM {_self.database}.INFORMATION_SCHEMA.DYNAMIC_TABLES
129-
WHERE schema_name = '{_self.schema_name}'
130-
AND name = '{_self.dynamic_table_name}'
131-
""")
132-
row = cursor.fetchone()
133-
134123
metadata: Dict[str, Any] = {
135124
"dynamic_table_fqn": fqn,
136125
"dynamic_table_name": _self.dynamic_table_name,
137126
"database": _self.database,
138127
"schema": _self.schema_name,
139128
}
140-
if row:
141-
columns = [c[0] for c in cursor.description]
142-
rd = dict(zip(columns, row))
143-
metadata.update({
144-
"scheduling_state": rd.get("SCHEDULING_STATE"),
145-
"last_refresh_status": rd.get("LAST_REFRESH_STATUS"),
146-
"target_lag": rd.get("TARGET_LAG"),
147-
"refresh_mode": rd.get("REFRESH_MODE"),
148-
"last_successful_refresh_time": str(rd.get("LAST_SUCCESSFUL_REFRESH_TIME"))
149-
if rd.get("LAST_SUCCESSFUL_REFRESH_TIME") else None,
150-
})
129+
130+
# SHOW DYNAMIC TABLES (not INFORMATION_SCHEMA.DYNAMIC_TABLES) —
131+
# INFORMATION_SCHEMA can be invisible to least-privilege roles
132+
# (e.g. DAGSTER_RUNNER) even with USAGE on the database. SHOW
133+
# only requires USAGE on the schema + any privilege on the DT.
134+
# Wrap in try/except so refresh still wins even if SHOW fails.
135+
try:
136+
cursor.execute(
137+
f"SHOW DYNAMIC TABLES LIKE '{_self.dynamic_table_name}' "
138+
f"IN SCHEMA {_self.database}.{_self.schema_name}"
139+
)
140+
row = cursor.fetchone()
141+
if row:
142+
columns = [c[0].lower() for c in cursor.description]
143+
rd = dict(zip(columns, row))
144+
metadata.update({
145+
"scheduling_state": rd.get("scheduling_state"),
146+
"last_refresh_status": rd.get("last_refresh_state"),
147+
"target_lag": rd.get("target_lag"),
148+
"refresh_mode": rd.get("refresh_mode"),
149+
"rows": rd.get("rows"),
150+
"bytes": rd.get("bytes"),
151+
"owner": rd.get("owner"),
152+
})
153+
except Exception as exc:
154+
context.log.warning(
155+
f"Could not read DT metadata for {_self.dynamic_table_name}: {exc}. "
156+
f"Refresh succeeded; emitting asset without enriched metadata."
157+
)
151158
return MaterializeResult(metadata=metadata)
152159
finally:
153160
cursor.close()

assets/data-warehouse/snowflake_task_execute_asset/component.py

Lines changed: 55 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -123,38 +123,67 @@ def snowflake_task_execute_asset(context: AssetExecutionContext) -> MaterializeR
123123
context.log.info(f"EXECUTE TASK {fqn}")
124124
cursor.execute(f"EXECUTE TASK {fqn}")
125125

126-
# Pull the most recent history entry so the materialization
127-
# has the run-id / state / scheduled_time on it.
128-
cursor.execute(f"""
129-
SELECT query_id, state, scheduled_time, query_start_time,
130-
completed_time, error_code, error_message
131-
FROM TABLE(INFORMATION_SCHEMA.TASK_HISTORY(
132-
TASK_NAME => '{_self.task_name}',
133-
SCHEDULED_TIME_RANGE_START =>
134-
DATEADD('minute', -5, CURRENT_TIMESTAMP())
135-
))
136-
ORDER BY scheduled_time DESC
137-
LIMIT 1
138-
""")
139-
row = cursor.fetchone()
140-
141126
metadata: Dict[str, Any] = {
142127
"task_fqn": fqn,
143128
"task_name": _self.task_name,
144129
"database": _self.database,
145130
"schema": _self.schema_name,
146131
}
147-
if row:
148-
columns = [c[0] for c in cursor.description]
149-
rd = dict(zip(columns, row))
150-
metadata.update({
151-
"query_id": rd.get("QUERY_ID"),
152-
"state": rd.get("STATE"),
153-
"scheduled_time": str(rd.get("SCHEDULED_TIME")) if rd.get("SCHEDULED_TIME") else None,
154-
"completed_time": str(rd.get("COMPLETED_TIME")) if rd.get("COMPLETED_TIME") else None,
155-
"error_code": rd.get("ERROR_CODE"),
156-
"error_message": rd.get("ERROR_MESSAGE"),
157-
})
132+
133+
# SHOW TASKS — works for least-privilege roles where
134+
# INFORMATION_SCHEMA may be invisible. Surfaces schedule + state.
135+
try:
136+
cursor.execute(
137+
f"SHOW TASKS LIKE '{_self.task_name}' "
138+
f"IN SCHEMA {_self.database}.{_self.schema_name}"
139+
)
140+
info = cursor.fetchone()
141+
if info:
142+
columns = [c[0].lower() for c in cursor.description]
143+
rd = dict(zip(columns, info))
144+
metadata.update({
145+
"task_state": rd.get("state"),
146+
"task_schedule": rd.get("schedule"),
147+
"warehouse": rd.get("warehouse"),
148+
"owner": rd.get("owner"),
149+
})
150+
except Exception as exc:
151+
context.log.warning(
152+
f"Could not read task metadata for {_self.task_name}: {exc}. "
153+
f"Execute succeeded; emitting asset without enriched metadata."
154+
)
155+
156+
# TASK_HISTORY is a table function — wrap in try/except so
157+
# EXECUTE TASK still wins even if the role can't read history.
158+
try:
159+
cursor.execute(f"""
160+
SELECT query_id, state, scheduled_time, query_start_time,
161+
completed_time, error_code, error_message
162+
FROM TABLE(INFORMATION_SCHEMA.TASK_HISTORY(
163+
TASK_NAME => '{_self.task_name}',
164+
SCHEDULED_TIME_RANGE_START =>
165+
DATEADD('minute', -5, CURRENT_TIMESTAMP())
166+
))
167+
ORDER BY scheduled_time DESC
168+
LIMIT 1
169+
""")
170+
row = cursor.fetchone()
171+
if row:
172+
columns = [c[0] for c in cursor.description]
173+
rd = dict(zip(columns, row))
174+
metadata.update({
175+
"query_id": rd.get("QUERY_ID"),
176+
"state": rd.get("STATE"),
177+
"scheduled_time": str(rd.get("SCHEDULED_TIME")) if rd.get("SCHEDULED_TIME") else None,
178+
"completed_time": str(rd.get("COMPLETED_TIME")) if rd.get("COMPLETED_TIME") else None,
179+
"error_code": rd.get("ERROR_CODE"),
180+
"error_message": rd.get("ERROR_MESSAGE"),
181+
})
182+
except Exception as exc:
183+
context.log.warning(
184+
f"Could not read TASK_HISTORY for {_self.task_name}: {exc}. "
185+
f"Execute succeeded; emitting asset without history metadata."
186+
)
158187
return MaterializeResult(metadata=metadata)
159188
finally:
160189
cursor.close()

0 commit comments

Comments
 (0)