🔍

全クエリがパスしたのにDatabricksダッシュボードは壊れていた話

に公開

はじめに

EXPLAINでヘルスチェックを実装した。117クエリすべてがパスした。だがダッシュボードは壊れていた。

この記事では、dbt更新後にDatabricks Lakeviewダッシュボードの破損を自動検知してSlack通知する仕組みを構築した経験を共有します。

  • dbt更新後のダッシュボード破損を自動検知し、Slackに通知する仕組みを作った
  • EXPLAINだけでは検知できない問題領域があり、query.historyとのハイブリッド方式に辿り着いた
  • Unity Catalog標準機能のみ、追加インフラ不要で実現

背景:なぜ必要だったか

弊社のとある事業のデータ基盤では、dbtで100以上のモデルを管理し、Databricks Lakeviewで30以上のダッシュボードを運用しています。事業の成長に伴い開発スピードが上がる中、以下のような問題が繰り返し発生していました。

  • dbtモデルでカラム名を is_optionalis_optional_plan にリネームしたら、ダッシュボードのフィルタが壊れた
  • 中間モデル rpt_monthly_metrics_v2 を削除したら、参照していたダッシュボードが全滅
  • 問題に気づくのは常にユーザーがダッシュボードを開いたタイミング

「dbt更新後、壊れてるか分かる仕組みがほしい」。これが出発点です。

dbt exposuresでダッシュボードとモデルの依存関係を定義・管理する方法もありますが、exposuresは「依存関係のドキュメント化」であり、ダッシュボードが実際に壊れているかの実行時検証はスコープ外です。今回は実行時の破損検知が目的なので、別のアプローチが必要でした。

最初のアプローチ:EXPLAINベース

Lakeview APIの GET /api/2.0/lakeview/dashboards/{id} でダッシュボード定義を取得し、serialized_dashboard 内の datasets[].queryLines からSQLを抽出。各SQLに EXPLAIN を実行して構造的な正しさを検証するアプローチです。

EXPLAINの利点はデータスキャンが発生しないこと。実行計画の生成のみでテーブル消失・カラム不足・型不整合を検知できます。ウェアハウスの実行コスト(DBU)は発生しますが、データを読まない分、通常のクエリと比べて非常に軽量です。

import json
from dataclasses import dataclass

@dataclass
class DashboardQuery:
    dashboard_id: str
    dashboard_name: str
    dataset_name: str
    query: str
    catalog: str
    schema: str

def _is_safe_for_explain(query: str) -> bool:
    """SELECT/WITHで始まるクエリのみEXPLAIN対象にする安全チェック"""
    for line in query.strip().splitlines():
        stripped = line.strip()
        if stripped and not stripped.startswith("--"):
            return stripped.upper().startswith(("SELECT", "WITH"))
    return False

def extract_queries(dashboard: dict) -> list[DashboardQuery]:
    dashboard_id = dashboard.get("dashboard_id", "")
    dashboard_name = dashboard.get("display_name", "")
    serialized = dashboard.get("serialized_dashboard")
    if not serialized:
        return []
    if isinstance(serialized, str):
        try:
            serialized = json.loads(serialized)
        except json.JSONDecodeError:
            return []
    queries = []
    for dataset in serialized.get("datasets", []):
        query_text = "\n".join(dataset.get("queryLines", []))
        if not query_text.strip() or not _is_safe_for_explain(query_text):
            continue
        queries.append(DashboardQuery(
            dashboard_id=dashboard_id,
            dashboard_name=dashboard_name,
            dataset_name=dataset.get("displayName", ""),
            query=query_text,
            catalog=dataset.get("catalog", ""),
            schema=dataset.get("schema", ""),
        ))
    return queries

_is_safe_for_explain でSQLコメント行をスキップしつつ、SELECT/WITH以外のクエリ(万が一DDLが含まれていた場合)をEXPLAIN対象から除外します。

壁にぶつかる:全クエリがパスしたのに壊れていた

本番で実行した結果、117クエリすべてパス。しかしダッシュボードは壊れていました。

原因はLakeviewダッシュボードのJSON構造にありました。

Lakeview内部JSONの構造例
{
  "datasets": [
    {
      "queryLines": ["SELECT * FROM rpt_monthly_summary"],
      "catalog": "my_catalog_stg",
      "schema": "dbt"
    }
  ],
  "pages": [
    {
      "layout": [{
        "widget": {
          "spec": {
            "encodings": {
              "fields": [{"fieldName": "is_optional", "type": "filter"}]
            }
          }
        }
      }]
    }
  ]
}

SQLクエリは SELECT * FROM rpt_monthly_summaryis_optional はSQL文中に一切登場せず、ウィジェットの encodings.fields に格納されています。EXPLAINはSQL文の実行計画しか検証しないため、ウィジェット側のカラム参照は検証範囲外です。

検知対象 EXPLAIN 備考
テーブル消失 DROP された場合
SQL内のカラム不足 SELECT col_a のような明示参照
フィルタ/ビジュアルのカラム参照 不可 ウィジェットJSON内に格納
ランタイムエラー 不可 データ依存のゼロ除算など

解決策:query.historyとのハイブリッド方式

Unity Catalogの system.query.history には query_source.dashboard_id フィールドがあり、ダッシュボード起因の失敗クエリだけを抽出できます。

WITH failures AS (
    SELECT
        query_source.dashboard_id  AS dashboard_id,
        COUNT(*)                   AS fail_count,
        ANY_VALUE(error_message)   AS sample_error,
        MAX(start_time)            AS last_failed_at
    FROM system.query.history
    WHERE query_source.dashboard_id IS NOT NULL
        AND execution_status = 'FAILED'
        AND start_time >= current_timestamp() - INTERVAL 24 HOURS
    GROUP BY query_source.dashboard_id
),
successes AS (
    SELECT
        query_source.dashboard_id  AS dashboard_id,
        MAX(start_time)            AS last_success_at
    FROM system.query.history
    WHERE query_source.dashboard_id IS NOT NULL
        AND execution_status = 'FINISHED'
        AND start_time >= current_timestamp() - INTERVAL 24 HOURS
    GROUP BY query_source.dashboard_id
)
SELECT f.*, s.last_success_at
FROM failures f
LEFT JOIN successes s ON f.dashboard_id = s.dashboard_id

last_success_at > last_failed_at なら修正済み(RESOLVED)としてスキップ。たとえばdbtビルド中にダッシュボード側の修正が入り、ヘルスチェック実行時点では既に直っているケースでも、誤って通知されることを防ぎます。

2層の役割分担

項目 EXPLAIN(事前) query.history(事後)
テーブル消失
SQLカラム不足
フィルタ/ウィジェット参照 不可
権限エラー 不可
未実行ダッシュボード 不可
コスト 軽微 軽微

互いの弱点を補完する設計です。EXPLAINは「まだ誰も開いていないダッシュボード」を事前検証。query.historyはウィジェット層の問題を事後的に拾います。

ただし、query.historyに失敗が記録されるには、ダッシュボードのクエリが実際に実行されている必要があります。まだ誰も開いておらず、スケジュール実行もされていないダッシュボードのエラーは検知できません。そこをEXPLAINが補完します。

lineageでスコープを絞る

system.access.table_lineage で、dbt管理テーブルに依存するダッシュボードだけに対象を絞ります。

SELECT
    entity_id                            AS dashboard_id,
    COLLECT_SET(source_table_full_name)  AS source_tables
FROM system.access.table_lineage
WHERE entity_type = 'DASHBOARD_V3'
    AND (source_table_full_name LIKE 'my_catalog.%'
         OR source_table_full_name LIKE 'my_catalog_stg.%')
GROUP BY entity_id

entity_type = 'DASHBOARD_V3' でLakeviewダッシュボードに絞り込めます。この結果は、スコープ絞り込みとSlack通知での調査導線の両方に活用します。上流テーブル一覧があれば「どのdbtモデルの変更が原因か」を即座に特定できます。

アーキテクチャ全体像

run_if: ALL_SUCCESS がポイントです。dbtが成功してテーブルが更新されたときだけヘルスチェックを実行します。dbtが失敗した場合はテーブルが更新されておらず、ダッシュボードの失敗はノイズになるためスキップします。

Slack通知

Dashboard Health Check Results After dbt Run

EXPLAIN tested: 117 queries -> 0 broken
query.history: 2 dashboards with failures (last 24h)

---
[prod] Monthly Management Dashboard [query.history]
Issues:
  3 distinct error(s) (6x): TABLE_OR_VIEW_NOT_FOUND: rpt_monthly_metrics_v2
Upstream tables:
  - my_catalog_stg.dbt.rpt_monthly_metrics_v2
  - my_catalog_stg.dbt.rpt_monthly_summary
  - ...and 58 more

上流テーブルを含めることで、通知→dbtモデル特定→git log→原因PR特定の調査フローが通知1つで完結します。

コスト

コンポーネント コスト 理由
EXPLAIN 軽微 データスキャンなし。ウェアハウスDBUのみ
system.query.history / table_lineage 軽微 システムテーブルへの軽量クエリ
Lakeview API $0 REST APIコール
Slack Webhook $0 標準機能

追加インフラや外部SaaSは不要。既存のDatabricksウェアハウスとUnity Catalogの標準機能だけで完結します。

まとめ

項目 内容
検知方式 EXPLAIN(事前)+ query.history(事後)のハイブリッド
スコープ dbt管理テーブルに依存するダッシュボードのみ(lineageで特定)
通知 Slack、上流テーブル情報付きで調査導線を確保
コスト 追加インフラ不要。既存ウェアハウスの軽微なDBUのみ

今回の仕組みはDatabricksの標準機能(Lakeview API、Unity Catalogシステムテーブル)だけで構成されており、追加のSaaSやインフラは不要です。dbtとLakeviewダッシュボードを運用しているチームであれば、同じアプローチをすぐに導入できます。


ダッシュボード監視で別のアプローチを取っている方がいれば、ぜひコメントで教えてください。

参考リンク

GENDA

Discussion