dbt の materialization にハマった話

に公開

こんにちは。ナウキャストでデータエンジニアをしているふぁるです。弊社で8月に開催したサマーインターンの準備中に、Snowflake上で同名のビューとテーブルを扱った際に、権限が意図せず失われるという問題に直面しました。

本記事では、この事例をもとに、その原因となったdbtとSnowflakeの仕様、そして具体的な対策について解説します。

忙しい人向け

  • Snowflakeの仕様: 同一スキーマ内では、同じ名前のビューとテーブルは共存できません
  • dbtの挙動: dbt run を実行すると、多くの場合、同名のオブジェクト(リレーション)が存在すれば、それを一度 DROP してから CREATE します
  • 結論: この2つの仕様の組み合わせにより、意図せずオブジェクトが置き換えられ、付与されていた権限が失われることがあります。

起きたことの整理

  1. 普段はビューとして提供しているモデルを、サマーインターン用にワンショットで切り出した同名のテーブルを作成することになった
  2. ローカルで materialized="table" として dbt run を実行してテーブルを作成。インターン期間中は問題なく参加者がテーブルを参照できていた
  3. 後日、インターンの参加者がテーブルを参照できない事態が発生。調査すると深夜に定期実行されるバッチで ビューが更新 されており、同名のビューとテーブルが衝突して、テーブルが事実上置換されていたことが判明。再度ローカルからテーブルを作成することで対応
    • 補足:インターンの参加者に対しては特定のテーブルのみアクセスできるように権限を調整していた。そのためビューにはアクセスできなかった

以上の事象を踏まえて、今回この記事で整理したい論点は以下の通り

  1. Snowflakeの仕様: なぜ同名のテーブルとビューが共存できないのか?
  2. dbtの挙動: dbt run は裏側でどのようなクエリを実行しているのか?

Snowflakeの仕様について

  • create or replace table の挙動について(ビューでもほぼ同じ)
    • 現行/指定のスキーマに新しいテーブルを作成
    • 既存テーブルを置き換え
    • 既存テーブルを変更
  • 公式docsには次のような注意書きがされていました

使用上の注意

  • スキーマに同じ名前のテーブルやビューを含めることはできません。テーブルを作成する場合:
    • 同じ名前のビューがスキーマにすでに存在する場合は、エラーが返され、テーブルは作成されません。
    • 同じ名前のテーブルがスキーマに既に存在する場合、オプションの OR REPLACE キーワードがコマンドに含まれていない限り、エラーが返され、テーブルは作成されません。

スキーマに同じ名前のテーブルやビューを含めることはできません」は極めて重要な仕様です。
通常、Snowflake上で直接SQLを実行すれば、同名オブジェクト作成時にエラーが出るため、この仕様を意識する機会は少ないかもしれません。しかし、dbtを介して操作すると、このエラーが表面化せず、オブジェクトの置き換えが静かに行われてしまうのです。

dbtの仕様について

  • dbtには materialized という config があります。これはモデルをウェアハウス上にどのように作成するかを決めるもので、例えば以下のように書けば mytable という名前のテーブルを生成することができます
mytable.sql
{{
    config(materialized="table")
}}

select ...(なんかクエリ)
  • 公式docsによればデフォルトは view になっているとのこと。他にも incremental, ephemeralを選ぶこともできます。それぞれどのような内容かはスコープから外れるので割愛します
  • dbt run の挙動について、これは "dbt-adapters" というレポジトリから紐解くことにしましょう。つまりコードリーディングによって裏側でどのように動いているのか理解していきます
  • 今回は同名のテーブルが既にあることを前提に materialized="view" として dbt run を実行したときの挙動を紐解いていきます

https://github.com/dbt-labs/dbt-adapters/blob/2ae4d7145496208507bf25f5f933cf271f29a39f/dbt-adapters/src/dbt/include/global_project/macros/materializations/models/view.sql#L24-L43

-> ここでまず走らせたいモデルと同名のリレーションがあるか探す。もし存在すれば drop_relation_if_exists というマクロを実行する


https://github.com/dbt-labs/dbt-adapters/blob/2ae4d7145496208507bf25f5f933cf271f29a39f/dbt-adapters/src/dbt/include/global_project/macros/relations/drop.sql#L37-L41

-> drop_relation_if_exists の定義はここにある。snowflakeの場合は snowflake__drop_relation というマクロを探して、なければ default__drop_relation を使う。たぶん後者でいいはず


https://github.com/dbt-labs/dbt-adapters/blob/2ae4d7145496208507bf25f5f933cf271f29a39f/dbt-adapters/src/dbt/include/global_project/macros/relations/drop.sql#L30-L34

-> default__get_drop_sql を実行する


https://github.com/dbt-labs/dbt-adapters/blob/2ae4d7145496208507bf25f5f933cf271f29a39f/dbt-adapters/src/dbt/include/global_project/macros/relations/drop.sql#L7-L23

-> マクロを実行すると snowflake__get_drop_table_sql が実行される


https://github.com/dbt-labs/dbt-adapters/blob/2ae4d7145496208507bf25f5f933cf271f29a39f/dbt-snowflake/src/dbt/include/snowflake/macros/relations/table/drop.sql#L1-L3

-> これは drop table if exists {{ relation }} cascade を返すマクロ


つまり、dbtは新しいオブジェクトを作成する前に、まず 同名のテーブルやビューをDROPする という挙動をします。これにより、Snowflakeの「同名オブジェクトは共存できない」という制約をクリアし、エラーなく処理を進めることができていたのです。

事象の再現

簡単な例を通じて裏側でどのようなコードが動いているのか確認してみましょう。今回の検証に使うテーブルを次のように作成しました。単純に3行3列のテーブルです

create or replace table mytable (
id integer,
value1 integer,
value2 integer
);

insert into mytable (id, value1, value2) values
    (1, 10, 5),
    (2, 20, 10),
    (3, 30, 20);

次にdbtで次のようなモデル calc_result を作って実行してみます。

{{ config(materialized="table") }}

select 
    id,
    value1 + value2 as value
from mytable

snowflake側で実行されるクエリを見てみましょう。こういうときにquery profileが大活躍します。とくに重要なものを書き出します

create or replace transient table <database>.<schema>.calc_result
    copy grants
    as (
select 
    id,
    value1 + value2 as value
from mytable
    )

→テーブルを作成するクエリですが、copy grants によって、もし同名のテーブルが存在していれば権限を継承するという内容です

次に materialized="view" として同じことをしてみましょう。このとき真っ先に実行されるのは

drop table if exists "<database>"."<schema>"."CALC_RESULT" cascade

です。cascade は drop 対象のテーブルを参照する依存オブジェクト(ビューとか)を削除してから削除をするというオプションになります。今回は依存しているものがないので関係ないですが、table1 -> view1 という関係があれば先に view1 を削除することになります

で以下のクエリによってビューが改めて生成されるという流れです。

create or replace view <database>.<schema>.calc_result
(
    "ID" COMMENT $$$$,  
    "VALUE" COMMENT $$$$
)
  copy grants 
  as (
select 
    id,
    value1 + value2 as value
from mytable
  )

この例は table -> view ですが、view -> table も同じ流れです

学び

今回の経験から得られた学びは非常にシンプルです。

  • 命名の衝突を避ける: そもそも、同じスキーマ内で同名のテーブルとビューを作成しない。スキーマを分ける、prefix/suffix(例: _v, _t)を付けるなどの命名規則を設けることで、この種の問題を根本的に回避できます。

一見遠回りに見えるかもしれませんが、dbtが裏側で何をしているのか、そして利用しているデータウェアハウスの仕様はどうなっているのかを正確に理解することが、安定したデータ基盤を運用する上でいかに重要であるかを再認識する良いきっかけとなりました。

Finatext Tech Blog

Discussion