概要
append は「追加」しか表現できない
incrementalのappend戦略は新しい行を足すだけで、既に取り込んだ注文のorder_statusが後からrefundedに変わるような「状態変化」を反映できない。
戦略を変えてもlookbackが狭ければ無意味
merge戦略に変えても、増分の再取込対象期間が短すぎれば、返金のように何日も後で確定するイベントをそもそも再スキャンできない。
可変マスタの都度JOINは過去を書き換える
UPDATEで上書きされるマスタ値を毎回JOINすると、過去のイベントに対する値まで「今の値」で遡って再計算されてしまう。イベント発生時点の値を確定記録するのが定石。
保持期間は業務要件から逆算する
パーティションの保持期間・依存関係の可視化・ビジネスルールの自動検証は、いずれも「正しさ」そのものではなく「壊れたことに気づける仕組み」を作る対策である。
問題
ECサイト MOps 販促システムチームでは、キャンペーンごとの費用対効果(ROI = 売上 − 返金 − クーポン原価)を日次で経営会議に報告する基盤 mart_campaign_roi_daily を運用している。
orders(注文、order_statusはconfirmed→refundedに後から変わりうる)とrefunds(返金明細、注文確定から平均5日・最大30日後に別イベントとして確定する)、campaign_coupon_cost(キャンペーンのクーポン原価単価マスタ。値引き施策の途中で単価改定が入ることがある)の3つをBigQuery × dbt Core 1.9+で結合し、Argo Workflows CronWorkflowで毎晩1回インクリメンタル実行している。
先月末に起きた出来事: 経営会議で「先週報告したキャンペーンAのROIが、今週の資料では±180万円もズレて再表示された」「返金確定後もキャンペーンBの売上が減っていない」という2つの指摘を受け調査したところ、以下の7つの設計・データ保持・ガバナンス上の問題が見つかった。
問題点を全て洗い出し、dbt incremental strategyの見直し(append → merge) × lookback windowの拡張 × クーポン原価を「注文成立時点の確定値」として記録する設計への変更 × BigQuery partition_expiration_daysの見直し × dbt exposuresによるBI依存関係の明示 × dbt Unit Testsによるビジネスルール検証を活用したBad→Goodリファクタリングを行ってください。
制約・前提条件
- BigQuery Standard SQL、dbt Core 1.9+(incremental merge、exposures、
unit_tests:がいずれも利用可能)、Argo Workflows 3.5+ orders:order_id,campaign_id,order_amount,ordered_at,order_status(confirmed/refunded),updated_atrefunds:refund_id,order_id,campaign_id,refund_amount,refunded_at。注文確定から平均5日・最大30日後に別イベントとして到着するcampaign_coupon_cost:campaign_id,coupon_cost_total(キャンペーン単価。改定時は既存行をUPDATEで上書きしており履歴を持たない)- 現状の
mart_campaign_roi_dailyのincremental設定はincremental_strategy: append、増分判定はWHERE ordered_at >= CURRENT_DATE - 1(直近1日分のみを毎晩追加) - 現状のモデルは
ordersのorder_amountをそのまま日次合計しており、refundsとのJOINが無い - ROI計算時、
campaign_coupon_costを都度JOINして最新のcoupon_cost_totalを参照している ordersテーブルはPARTITION BY DATE(ordered_at)されており、partition_expiration_days = 90が設定されている。キャンペーンは最長180日間実施されるものがある- dbt exposuresは未定義で、経営会議向けBIダッシュボード(Looker Studio)がこのマートに依存していることがdbt lineageから追跡できない
- dbt testsは
mart_campaign_roi_dailyに対して行数チェックとnot_null(order_id)のみで、「返金額が対応する注文額を超えない」というビジネスルールを検証するテストが無い
partition_expiration_days見直し)+ dbt exposures定義 + dbt Unit Test(ビジネスルール検証)+ 実行例(input→output)+ 設計意図の説明
悪いコード (Before)
{{ config(
materialized='incremental',
-- 問題①: 状態変化を反映できないappend戦略のまま
incremental_strategy='append'
) }}
SELECT
o.order_id, o.campaign_id, o.ordered_at,
o.order_amount,
-- 問題④: 都度最新値をJOINする可変マスタ
c.coupon_cost_total,
o.order_amount - c.coupon_cost_total AS roi_amount
-- 問題②: refundsとのJOINが無く返金が一切反映されない
FROM {{ ref('stg_orders') }} o
JOIN {{ ref('campaign_coupon_cost') }} c USING (campaign_id)
{% if is_incremental() %}
-- 問題③: 直近1日分しか再取込しないため最大30日後の返金確定を拾えない
WHERE o.ordered_at >= CURRENT_DATE - 1
{% endif %}
-- 問題⑤: partition_expiration_daysが90日固定
CREATE TABLE `mops-project.raw_events.orders` (...)
PARTITION BY DATE(ordered_at)
OPTIONS (partition_expiration_days = 90);
-- キャンペーンは最長180日 → 前半のパーティションが月次集計前に消える
-- 問題⑥: exposures.ymlが存在せず、Looker Studioダッシュボードの
-- 依存がdbt lineageから追跡できない
-- 問題⑦: schema.ymlのテストは行数チェックとnot_nullのみ
-- 「返金額 <= 注文額」というビジネスルールを検証するテストが無い
ヒント(段階的開示)
ヒント1 — 方向性
append戦略は「新しい行を足す」ことしかできず、「既に取り込んだ行の状態が後から変わる」というイベントを表現できない。さらに増分の対象期間(lookback window)が短すぎると、たとえ戦略を変えても遅れて確定するイベントを拾えない。(2) 可変ディメンションの層——campaign_coupon_costのようにUPDATEで上書きされるマスタを都度JOINすると、「過去のある時点で実際に適用されていた値」ではなく「今この瞬間の値」で過去の集計まで書き換わってしまう。(3) 保持・ガバナンスの層——パーティションの保持期間はキャンペーンの実施期間という業務要件から逆算する必要があり、依存関係の明示とビジネスルールの自動検証は「壊れたことに気づける」ための最後の砦になる。
ヒント2 — アプローチ
- 問題①: appendで状態変化が反映されない →
incremental_strategy: merge+unique_key: order_idに変更し既存行を上書きできるようにする - 問題②: refundsとのJOINが無く返金が売上から引かれない → LEFT JOINして
order_amount - COALESCE(refund_amount, 0)でROIに反映する - 問題③: lookback windowが1日固定で最大30日後の返金を拾えない → 返金確定までの最大日数(30日)+バッファを変数化し再取込対象を拡張する
- 問題④: 可変マスタを都度JOINするため単価改定で過去ROIが遡って書き換わる → 注文成立時点の単価を
coupon_cost_appliedとして確定記録する - 問題⑤: partition_expiration_days=90でキャンペーン前半データが消える → 最大実施期間+バッファ(210日)に見直す、または長期保持のarchiveテーブルを分離する
- 問題⑥: exposuresが無くBI依存が見えない →
exposures:ブロックでダッシュボード依存を宣言する - 問題⑦: ビジネスルール検証テストが無い → dbt Unit Test(fixtureベース)+本番データ向けsingular testで「返金額 ≤ 注文額」を検証する
ヒント3 — コードの骨格
-- incremental mergeの骨格(既存行の状態変化を反映)
{{ config(
materialized='incremental',
incremental_strategy='merge',
unique_key='order_id'
) }}
SELECT o.order_id, o.campaign_id, o.ordered_at,
o.order_amount, o.coupon_cost_applied,
COALESCE(r.refund_amount, 0) AS refund_amount
FROM {{ ref('stg_orders') }} o
LEFT JOIN {{ ref('stg_refunds') }} r USING (order_id)
{% if is_incremental() %}
WHERE o.ordered_at >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL {{ var('refund_lookback_days', 35) }} DAY)
{% endif %}
# dbt Unit Testの骨格
unit_tests:
- name: test_refund_not_exceeding_order_amount
model: mart_campaign_roi_daily
given:
- input: ref('stg_orders')
rows: [{order_id: "o1", order_amount: 1000}]
- input: ref('stg_refunds')
rows: [{order_id: "o1", refund_amount: 1500}] # 不正データ
expect:
rows: []
問題点分析(7点)
| # | 問題点 | 分類 | 改善方法 |
|---|---|---|---|
| 1 | append戦略で状態変化(refunded)が反映されない | 取込層 | incremental_strategy: merge + unique_key |
| 2 | refundsとのJOINが無い | 取込層 | LEFT JOIN + COALESCEで減算 |
| 3 | lookback windowが1日固定 | 取込層 | refund_lookback_daysで35日に拡張 |
| 4 | 可変マスタの都度JOINで過去ROIが遡及変化 | 可変性層 | coupon_cost_appliedとして確定記録 |
| 5 | partition_expiration_days=90固定 | 保持期間層 | 210日へ拡張 + archive分離 |
| 6 | dbt exposures未定義 | ガバナンス層 | exposures.ymlでBI依存を明示 |
| 7 | ビジネスルール検証テストが無い | 回帰防止層 | dbt Unit Test + singular test |
ROI遡及的乖離の比較図 — Bad vs Good の比較
模範解答
{{ config(
materialized='incremental',
incremental_strategy='append'
) }}
SELECT o.order_id, o.campaign_id, o.ordered_at,
o.order_amount, c.coupon_cost_total,
o.order_amount - c.coupon_cost_total AS roi_amount
FROM {{ ref('stg_orders') }} o
JOIN {{ ref('campaign_coupon_cost') }} c USING (campaign_id)
{% if is_incremental() %}
WHERE o.ordered_at >= CURRENT_DATE - 1
{% endif %}
-- models/marts/mart_campaign_roi_daily.sql
{{ config(
materialized='incremental',
-- 修正①: append(挿入のみ)からmergeへ変更し、
-- 返金確定・ステータス変更による既存行の状態変化を反映できるようにする
incremental_strategy='merge',
unique_key='order_id',
partition_by={'field': 'ordered_at', 'data_type': 'timestamp', 'granularity': 'day'},
cluster_by=['campaign_id']
) }}
WITH orders_with_refund AS (
SELECT
o.order_id,
o.campaign_id,
o.ordered_at,
o.order_status,
o.order_amount,
o.coupon_cost_applied, -- 修正④: 確定単価を参照
COALESCE(r.refund_amount, 0) AS refund_amount -- 修正②: refunds反映
FROM {{ ref('stg_orders') }} o
LEFT JOIN {{ ref('stg_refunds') }} r
ON o.order_id = r.order_id
{% if is_incremental() %}
-- 修正③: 返金確定の最大想定日数(30日)+バッファ(5日)まで再取込対象を広げる
WHERE o.ordered_at >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL {{ var('refund_lookback_days', 35) }} DAY)
{% endif %}
)
SELECT
order_id, campaign_id, DATE(ordered_at) AS order_date,
order_amount, refund_amount, coupon_cost_applied,
order_amount - refund_amount - coupon_cost_applied AS roi_amount
FROM orders_with_refund
-- 修正④: campaign_coupon_costは「今後の新規注文に適用する単価」の
-- 現在値マスタとしてのみ使い、注文成立時点でcoupon_cost_appliedとして
-- orders側に確定記録する。以後のROI再集計はこの確定値を参照するため、
-- 単価改定後にバッチを再実行しても過去日のROIが遡って変わらない。
SELECT
o.order_id, o.campaign_id, o.order_amount,
o.ordered_at, o.order_status,
c.coupon_cost_total AS coupon_cost_applied -- 注文成立時点の単価を固定
FROM {{ source('ecommerce', 'raw_orders') }} o
JOIN {{ source('ecommerce', 'campaign_coupon_cost') }} c
USING (campaign_id)
WHERE o.order_status IN ('confirmed', 'refunded')
-- coupon_cost_appliedはINSERT時に一度だけ書き込み、
-- 以後campaign_coupon_costマスタが改定されてもUPDATEしない(イミュータブル)
CREATE TABLE `mops-project.raw_events.orders` (...)
PARTITION BY DATE(ordered_at)
OPTIONS (partition_expiration_days = 90);
-- 問題⑤: 最長180日キャンペーンの前半データが月次再集計前に消える
-- 修正⑤: キャンペーン最大実施期間(180日)+再集計バッファ(30日)を見て
-- partition_expiration_daysを210日に拡張する。
ALTER TABLE `mops-project.raw_events.orders`
SET OPTIONS (partition_expiration_days = 210);
-- 長期保持が必要なROI集計結果は別のarchiveテーブルに寄せ、
-- 生ログ側は短期保持のままコストを抑える。
CREATE TABLE IF NOT EXISTS `mops-project.marts.mart_campaign_roi_daily_archive`
(
order_id STRING NOT NULL, campaign_id STRING NOT NULL,
order_date DATE NOT NULL, order_amount NUMERIC,
refund_amount NUMERIC, coupon_cost_applied NUMERIC, roi_amount NUMERIC
)
PARTITION BY order_date
CLUSTER BY campaign_id
OPTIONS (partition_expiration_days = NULL); -- 無期限保持
exposures:
- name: campaign_roi_executive_dashboard
type: dashboard
maturity: high
url: https://lookerstudio.google.com/campaign-roi-dashboard
description: >
経営会議で毎週参照するキャンペーンROIダッシュボード。
mart_campaign_roi_dailyのスキーマ変更は必ずこのダッシュボードへの
影響を確認してからリリースする。
depends_on:
- ref('mart_campaign_roi_daily')
owner:
name: MOps 販促システムチーム
email: mops-team@example.com
# models/marts/_mart_campaign_roi_daily.yml
unit_tests:
- name: test_refund_not_exceeding_order_amount
model: mart_campaign_roi_daily
given:
- input: ref('stg_orders')
rows:
- {order_id: "o1", campaign_id: "c1", order_amount: 1000,
coupon_cost_applied: 100, ordered_at: "2026-09-01T00:00:00",
order_status: "refunded"}
- input: ref('stg_refunds')
rows:
# 不正データ: 返金額(1500)が注文額(1000)を超えている
- {order_id: "o1", campaign_id: "c1", refund_amount: 1500,
refunded_at: "2026-09-05T00:00:00"}
expect:
rows: []
-- tests/assert_refund_not_exceeding_order_amount.sql
-- 修正⑦: 本番データに対しても、返金額が注文額を超える行が無いことを検証する。
SELECT order_id, order_amount, refund_amount
FROM {{ ref('mart_campaign_roi_daily') }}
WHERE refund_amount > order_amount
| 問題 | 修正内容 | 効果 |
|---|---|---|
| ① appendで状態変化未反映 | incremental_strategy: merge + unique_key | 返金・ステータス変更が既存行に反映される |
| ② refunds未JOIN | LEFT JOIN + COALESCEで減算 | 返金が正しくROIから引かれる |
| ③ lookback windowが1日固定 | refund_lookback_daysを35日に拡張 | 最大30日後の返金確定を再取込できる |
| ④ 可変マスタの都度JOIN | coupon_cost_appliedとして確定記録 | 単価改定後も過去ROIが変わらない |
| ⑤ partition_expiration_days=90 | 210日へ拡張+archive分離 | 180日キャンペーンでもデータが欠落しない |
| ⑥ exposures未定義 | exposures.ymlでBI依存を宣言 | スキーマ変更時の影響評価漏れを防止 |
| ⑦ ビジネスルール検証テスト無し | dbt Unit Test + singular test | 不正な返金データをCIで検知 |
実行例(input → output)
input(改善前のシナリオ):
キャンペーンAの注文order_id=o1(order_amount=10,000円)が9月1日に成立し、9月20日に全額返金(refund_amount=10,000円)が確定した。同時期、キャンペーンAのクーポン単価がcoupon_cost_total=500円から800円に9月10日改定された。
output(Before・問題のある実装):
append戦略のためincrementalの再取込対象は「直近1日分」のみで、9月20日の返金イベントは9月1日の注文行を更新しない。9月1日時点では10,000円の売上がそのまま残り続ける。さらに月次バッチが再実行されると、campaign_coupon_costの現在値(800円)が都度JOINされるため、実際は500円だった9月1日〜9日のROIまで800円で再計算され、経営会議に報告済みだった数値と食い違う。
output(After・改善後):
incremental_strategy: merge + unique_key: order_id + refund_lookback_days=35により、9月20日の返金確定が9月1日の注文行を正しく上書きし、roi_amountから10,000円が減算される。coupon_cost_appliedは注文成立時点(9月1日、単価500円)でstg_ordersに確定記録されているため、9月10日の単価改定以降にバッチを再実行しても9月1日〜9日のROIは500円のまま変わらない。partition_expiration_days=210により180日キャンペーンの月次再集計でも過去データが欠落しない。
ポイント解説
appendはイベントの「追加」しか表現できず、「既存の事実の状態変化」は表現できない — 注文ステータスのような後から変わりうる値を扱うモデルはmerge + unique_keyを基本形にする。実務への応用
「イベント発生時点で値を確定記録する」「後から状態が変わりうる事実はmergeで扱う」「保持期間は業務要件から逆算する」という3つの設計判断は、ROIマートに限らず、会員ランク特典の原価計算・配送料率の後からの改定・在庫予約のキャンセル反映など、MOpsチームが扱う「後から取り消される・変わりうる業務イベント」全般に再利用できる。特に「経営会議に報告済みの数値が後から静かに変わる」というインシデントは、可変ディメンションの都度JOINが原因であることが多く、他の経営指標マートにも同じ観点でレビューをかける価値がある。
今日のまとめ
appendで追加し続け、「後から改定されうるマスタ値」を都度JOINで参照すると、どちらも過去の確定していたはずの集計値が静かに書き換わるという同じ種類の事故を引き起こす。 イベント発生時点で値を確定記録する設計と、incremental戦略・lookback window・保持期間という3つのパラメータを業務要件(返金確定までの日数・キャンペーン最長期間)から逆算することが、経営指標マートの信頼性を守る。
次のステップ
- 発展問題: 今回は返金の遅延到着が最大30日という前提だったが、もし返金確定までの期間が業務上「上限が無い」(法的には無期限にクレーム可能)場合、incrementalのlookback windowという設計はどこまで有効か。全件再スキャン(full-refresh)との使い分け基準を設計せよ。
- 参考: dbt incremental strategy(append / merge) / dbt exposures / dbt Unit Tests(dbt 1.8+) / BigQuery partition_expiration_days / SCD Type 1 と Type 2 の使い分け