ソースについて考えてみた話
dbt Fundamentalsの「Sources」のセクションについて理解した内容を言葉にしてみた。 かなりの部分で感想を織り交ぜてみた。この記事は前回の続き。 [clink url=\"https://ikuty.com/2023/07/15/dbt_models/\"] dbtはModularityのコンセプトに基づいてクエリをバラし上流から下流へと続くデータフローを 構築するフレームワーク。上流から下流までどのようにバラしても自由ではあるが、 何度か似たような仕組みを作っていくと、共通して見られる特徴に気づくことがある。 例えば、データを取り込んで、加工して綺麗にして、中間処理をして、BIやMLに渡す、といったように、 機能単位で処理をレイヤ化しておくと見通しが良くなることに気づく。 dbtはそのバラし方についてある程度規定していて以下のようにレイヤリングすることが示されている。 Source Staging Intermediate Fact Dimension この記事はSourceレイヤについての記事。 [arst_toc tag=\"h4\"] 外部インターフェースとDRY原則 データ分析基盤の構築に限らず、システムを設計する際に外部データとのインターフェースを 独立して検討が必要な設計項目とすることが多い気がしている。 それは何故かというと「自分たちの管理外にあるものであって変わり続けるもの」だからだと思う。 インターフェース仕様を定義し変化を制御しようとする。制御下にある変化を受け入れる必要がある。 変化する対象をハードコードした場合、変化が起きたときに全てを変更する必要がある。 もし対象を1箇所に記述しそれを論理的に参照する仕組みがあれば、変化に対する変更は1回で済む。 実際には、想定する粒度や概念の範囲を逸脱した場合は書き直しが必要だろうと思うので、 それは見通しが甘かったことに対する罰として受け入れないといけない。 インターフェース仕様の想定の甘さはシステム内部のそれと比べて影響が大きい。 モダンな言語やフレームワークでは、同じ変更を何度も行わせるようなタルいことをさせないように 作られている。これはDRY(Don\'t Repeat Yourself)原則と言い一般的な設計論として話される。 dbtはELTを前提としていて、外部データはそのままテーブルにロードすべし、としている。 dbtにとって、外部データをロードした一番最初のテーブルが「生」であり変化し得るが、 DRY原則に従って、1箇所で定義し抽象化したものを使いまわせるようになっている。 Sourceの定義と利用 さて、dbtのSourceによってどのようにDRYが実現できるか見てみる。まずは生クエリ。 CTEにより記述され、1個目のsourceを2個目のstagedで使用している。 ikuty_raw.jaffle_shop.customers が生データを格納したテーブル。 ハードコードされた状態。 with source as ( select * from ikuty_raw.jaffle_shop.customers ), staged as ( select id as customer_id, first_name, last_name from source ) select * from staged ; 次にdbt。model-pathにsource.ymlというファイルを配置する。 生クエリのsourceがYMLで定義されている。jaffle_shopという名前のSourceを指定している。 スキーマやテーブルが変更されたとしても、このファイルを変更すれば良い。 version: 2 sources: - name: jaffle_shop database: ikuty_raw schema: jaffle_shop tables: - name: customers - name: orders 生クエリをモデル上で以下のように書き直す。 この際、上で定義したjaffle_shop Sourceを {{source()}} により参照している。 select id as customer_id, first_name, last_name from {{ source(\'jaffle_shop\', \'customers\') }} 古いデータに基づく分析から得られた意思決定はゴミ? データ分析界隈では「データの新しさ」がしばしば重要なトピックとなる。 しかし、それはなぜなのか上手い説明を聞いたことがなかった。 dbt公式が用意するドキュメントの中に、dbtが生まれた経緯が説明されているものがあり、 その中で、風が吹けば桶屋が儲かる的なノリでこう書いてある。 explicitにデータ鮮度の重要さを示すものではないが、 「古いデータに基づく分析から得られた意思決定はゴミ」ぐらいの気持ちになった。 The dbt Viewpoint https://docs.getdbt.com/community/resources/viewpoint Quality Assurance Bad data can lead to bad analyses, and bad analyses can lead to bad decisions. データ鮮度の定義と実行 データ分析基盤には、データを定期的に取り込む類のタスクがある。 dbtは取り込みの度に取り込んだデータの鮮度を確認できる機能を備えている。 「定期的」とは、serviceやdaemon的な何かが動いてデータソースを見にいく訳ではなく、 dbtを実行して取り込む度に毎度値を参照するという意味。 物理的には、「新鮮であると見做すことができるレコードと現在との間の許容可能な時間」 を定義し、許容できない時間差を検知することでデータが古いのか新しいのかを判断させる。 この辺り、Declarativeであり、新しい何か的な印象を持つ。 sourceの定義ファイルにfreshnessブロックを定義する。 公式による仕様の説明は以下の通り。 version: 2 sources: - name: freshness: warn_after: count: period: minute | hour | day error_after: count: period: minute | hour | day filter: loaded_at_field: tables: - name: freshness: warn_after: count: period: minute | hour | day error_after: count: period: minute | hour | day filter: loaded_at_field: ... loaded_at_fieldにデータ鮮度の判定に利用するカラムを指定する。 loaded_at_fieldと現在時刻の差がcount、periodに定義した時間を超えていた場合にレポートする。 レポートには警告とエラーの2種類が存在し、warn_afterに警告、error_afterにエラーの場合を書く。 count,periodの単語選びのセンスがちょっと分からない。periodが単位、countが数値である。 例えばcount=2、period=dayなら「2日」。Declarativeに読めば良いのか? freshnessブロックは継承関係を持たせることができる。 つまりsources直下に書いたものでデフォルトを定義し、tables配下に書いたもので上書きできる。 loaded_at_fieldカラムはtimestamp型かつUTCである必要があり、変換例が公式に載っている。 # If using a date field, you may have to cast it to a timestamp: loaded_at_field: \"completed_date::timestamp\" # Or, depending on your SQL variant: loaded_at_field: \"CAST(completed_date AS TIMESTAMP)\" # If using a non-UTC timestamp, cast it to UTC first: loaded_at_field: \"convert_timezone(\'UTC\', \'Australia/Sydney\', created_at_local)\" ここでは以下のような定義とする。 version: 2 sources: - name: jaffle_shop database: ikuty_raw schema: jaffle_shop tables: - name: customers - name: orders loaded_at_field: _etl_loaded_at freshness: warn_after: {count: 12, period: hour} error_after: {count: 24, period: hour} dbt source freshnessコマンドによりデータ鮮度が評価される。 _etl_loaded_atの最大値が現在よりも12時間以上前だったのでWARNが出た。 $ dbt source freshness 15:10:34 Running with dbt=1.5.0 15:10:34 Found 1 model, 0 tests, 0 snapshots, 0 analyses, 321 macros, 0 operations, 0 seed files, 2 sources, 0 exposures, 0 metrics, 0 groups 15:10:34 15:10:35 Concurrency: 1 threads (target=\'dev\') 15:10:35 15:10:35 1 of 1 START freshness of jaffle_shop.orders ................................... [RUN] 15:10:37 1 of 1 WARN freshness of jaffle_shop.orders .................................... [WARN in 1.72s] 15:10:37 Done. 宣言的記述について ソフトウエアパラダイムの1つとして市民権を得ている宣言的(Declarative)記述について書いてみる。 開発者人口が多いVue.jsかReact.jsで爆発的に認知され(ひと昔前に)一気に当たり前になった印象がある。 歴史的な経緯からフロントコードは非同期かつイベント駆動であって、酷い可読性だった覚えがある。 onClickの中にonClickを書いて、その中にonClickを書いて...みたいなことが起こり得た。 JSの言語仕様でasync awaitパターンがサポートされステートマシンを合理的に記述できるようになった。 「テキストボックスにバインドする変数はX」と書くだけで、関係する処理が省略できてむっちゃ楽。 個人的には構成管理ツールのAnsibleで宣言的記述の良さを実感できた気がする。 構成管理の界隈では、冪等性が重要で例えば「nginxは192.168.1.64:8080でlistenすること」 のように定義しさえすれば、何度実行しても設定が定義値であることが保証される仕組みが欲しい。 もし状態を宣言的に記述できなければ、細かい処理を自力で実装しなければならなくなる。 「listen: 192.168.1.64:8080」と書いて実行しさえすれば良いならむっちゃ楽。 ミドルウェアとデータの違いはあるが、実体があるものを抽象化し振る舞いを状態定義する様から、 dbtはAnsibleやTerraformに近い気がする。(データの構成管理をしているような...) freshnessを確認するコードをJavaで書けとか言われたらタル過ぎるので、 dbtで当たり前のように宣言的に記述できるのはありがたい。 まとめ dbt Fundamentalsの「Sources」セクションを聴いて、内容を文書化してみた。 Sourceを抽象化することでDRYを実現できること、 抽象化した先でデータ鮮度の確認ができることについて定義や実行例を追って確認してみた。