概要
消去は「全ての複製」が対象
dbt が作る派生テーブル、BigQuery 自身が持つ過去版、人が作った退避コピー。複製ごとに「消える条件」が違う。
「消えた」には3段階ある
DML 直後 → time travel 終了 → fail-safe 終了。「復元できない」と言えるのは3段目から。
識別子と事実を分ける
取引の「いつ・いくら」は残し「誰が」だけ外す。消去と経営指標の安定が両立する。
棚卸しは宣言から生成
dbt の meta.erasure に宣言 → manifest から列挙 → CI で付け忘れ検査 → dbt test で 0 件確認。
問題
ECサイトの MOps 販促システムチームは、会員が退会時に「個人データの消去」を請求した場合の分析基盤側の処理 member-erasure-job を運用している(Argo Workflows CronWorkflow、毎晩 02:00)。
先週、次の2件が同時に起きた。
- 消去受付から20日後の元会員に誕生日クーポンのメールが届いた(本人には受付当日に「消去が完了しました」と通知済み)
- 消去処理の翌朝、経営会議に報告済みの8月キャンペーン売上が38万円減って再表示された(9/16 の ROI 乖離インシデントの再発を疑われた)
問題点を全て洗い出し(7点)、改善後の設計とコードを示してください。
制約・前提条件
- BigQuery(オンデマンド課金)、dbt Core 1.9+、Argo Workflows 3.5+、Python 3.13
- 会員数 約800万人、消去請求は 月 約3,000件(1日 約100件)(仮定)
fct_ordersは約1.2TB、member_idでクラスタリングされていない(仮定)raw.membersは CRM から 旧ストリーミング API(tabledata.insertAll)で書き込まれている- データセットの time travel 窓は既定値(7日)のまま
- 法定保存が必要な取引の原本は基幹の会計システム側が7年保存しており、BigQuery はその分析用コピー(この前提が判断を左右する)
- 本人への「消去完了」通知は「当社システムから復元できない状態になった日」を通知する(法務と調整済み)
期待する回答形式: (1) 問題点の列挙 (2) 改善後コード(Google スタイル docstring・インラインコメント・名前付き定数)(3) 実行例 (4) 消去完了 SLA の日数の導出 (5) 採らなかった案とその理由
悪いコード (Before)
from google.cloud import bigquery
client = bigquery.Client()
def run():
reqs = client.query(
"SELECT member_id FROM `mops-project.crm.erasure_requests` "
"WHERE status = 'requested'"
).result()
for r in reqs:
try:
# 会員マスタから1件ずつ削除
client.query(
f"DELETE FROM `mops-project.raw.members` "
f"WHERE member_id = '{r.member_id}'"
).result()
# 注文ファクトからも削除(個人に紐づくので全部消す)
client.query(
f"DELETE FROM `mops-project.mart.fct_orders` "
f"WHERE member_id = '{r.member_id}'"
).result()
client.query(
f"UPDATE `mops-project.crm.erasure_requests` "
f"SET status = 'done' WHERE member_id = '{r.member_id}'"
).result()
send_completion_mail(r.member_id) # 「消去が完了しました」
except Exception:
pass # 1件失敗しても全体は止めない
# snapshots/snap_members.yml
snapshots:
- name: snap_members
relation: ref('stg_members')
config:
unique_key: member_id
strategy: check
check_cols: [email, phone, birthday, prefecture]
hard_deletes: new_record # 退会を検知して履歴行を追加
-- models/marts/mart_birthday_coupon_targets.sql
{{ config(materialized='incremental',
incremental_strategy='merge', unique_key='member_id') }}
SELECT member_id, email, birthday
FROM {{ ref('int_member_profile') }}
{% if is_incremental() %}
WHERE updated_at >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 2 DAY)
{% endif %}
-- 毎週日曜: PII テーブルの table snapshot(有効期限なし)
CREATE SNAPSHOT TABLE `mops-project.backup.members_{{ ds_nodash }}`
CLONE `mops-project.raw.members`;
ヒント(段階的開示)
ヒント1 — 方向性
ヒント2 — アプローチ
incremental_strategy='merge'は上流から消えた行を下流から消さない(来た行を upsert するだけ)- snapshot の
hard_deletes: new_recordは削除マーカー行を足すだけで、過去の email・phone の履歴行は残る - table snapshot は読み取り専用で DML できない。消えるのは有効期限が来たときだけ
- 削除行は time travel 窓(既定7日)の間
FOR SYSTEM_TIME AS OFで読め、その後 fail-safe(7日、利用者は操作不可)に残る - 売上が変わったのは「行ごと消した」から。取引の金額は個人データではない。識別子だけ外せば集計値は変わらない
- 対象テーブルを手書きで持つと新モデル追加時に漏れる。dbt の
metaに宣言させ manifest から列挙する
ヒント3 — 骨格
erasure_ledger(台帳): requested → propagated → physically_erased → notified
1. 受付から2時間以上経った請求だけをバッチに取る(ストリーミングバッファ回避)
2. manifest.json から meta.erasure を持つ全ノード(model + snapshot)を列挙
3. テーブルごとに「1バッチ1文」の DELETE / UPDATE(IN (SELECT ... FROM ledger))
4. 影響行数を監査ログへ。1つでも失敗したら status を進めず例外で落とす
5. dbt test: 消去済み member_id が meta.erasure 付き全モデルで 0 件
6. propagated から N 日後に physically_erased → そこで初めて本人通知
N = time travel 窓 + fail-safe 7日(table snapshot の有効期限と比べて大きい方)
問題点分析(7点)
| # | 問題点 | 分類 | 何が起きたか | 改善方法 |
|---|---|---|---|---|
| 1 | 下流の派生テーブルに伝播しない | dbt派生 | incremental merge の mart に元会員が残り、20日後にクーポン配信 | meta.erasure 付き全ノードへ DELETE |
| 2 | snapshot 履歴行に PII が残る | dbt派生 | hard_deletes は削除を「記録」するだけ | snapshot も消去対象に宣言し行ごと DELETE |
| 3 | 「消去完了」通知が早すぎる | BQ過去版 | DML 直後も time travel で読める | time travel 48h + fail-safe 7日後に通知 |
| 4 | 期限なし table snapshot | 退避コピー | 読み取り専用で DML 不可、永久残存 | expiration_timestamp を 7日に |
| 5 | 取引行を行ごと削除 | 消去方式 | 報告済みの8月売上が38万円変動 | member_id を ERASED に置換(仮名化) |
| 6 | 例外握り潰し・部分消去 | 運用 | バッファ内の行で DML 失敗を黙殺、テーブル間で不整合 | 2時間猶予+失敗は例外で落とす+冪等な再実行 |
| 7 | 棚卸し・検証なし/DML 浪費 | 運用 | 1日100回 × 1.2TB スキャン | 1テーブル1文 + dbt test + CI の meta 検査 |
複製の消え方 — Bad vs Good(SVG)
上段: 複製は3種類で、消える条件がそれぞれ違う。下段: Good では「一番遅く消える複製」から完了日を逆算して通知する。
模範解答
黄色 = 設計意図のコメント、緑 = Bad の問題番号を直した箇所。
for r in reqs:
try:
client.query(
f"DELETE FROM `mops-project.raw.members` "
f"WHERE member_id = '{r.member_id}'" # ⑦ 1件ずつ ⑥ f-string
).result()
client.query(
f"DELETE FROM `mops-project.mart.fct_orders` " # ⑤ 行ごと
f"WHERE member_id = '{r.member_id}'"
).result()
# ① mart ② snapshot ④ table snapshot は対象外
client.query(... "SET status = 'done'" ...).result()
send_completion_mail(r.member_id) # ③ 即日「完了」
except Exception:
pass # ⑥ 握り潰し
"""退会会員の個人データ消去を、分析基盤の下流まで伝播させるジョブ。
Argo Workflows の CronWorkflow から1日1回呼ばれる。消去対象テーブルは
コードに書かず、dbt manifest.json の meta.erasure から列挙する。
"""
from __future__ import annotations
import datetime as dt
import json
import uuid
from dataclasses import dataclass
from enum import StrEnum
from pathlib import Path
from google.cloud import bigquery
LEDGER = "mops-project.governance.erasure_ledger"
AUDIT_LOG = "mops-project.governance.erasure_audit_log"
# ⑥ insertAll の行はバッファにある間 DML できない。受付から待つ時間
STREAMING_BUFFER_GRACE = dt.timedelta(hours=2)
# PII データセットの max_time_travel_hours=48 と一致させる
TIME_TRAVEL_DAYS = 2
# time travel の後に BigQuery が保持する期間(利用者は短縮できない)
FAIL_SAFE_DAYS = 7
# ④ PII テーブルの table snapshot に付ける有効期限
TABLE_SNAPSHOT_TTL_DAYS = 7
# ③ 「復元不能」と言えるまでの日数 = 一番遅く消える複製で決まる
PHYSICAL_ERASURE_DAYS = max(TIME_TRAVEL_DAYS + FAIL_SAFE_DAYS, TABLE_SNAPSHOT_TTL_DAYS)
# 集計(件数・金額)は残し、人には戻れない値
ERASED_MEMBER_ID = "ERASED"
class ErasureAction(StrEnum):
"""消去のやり方。"""
DELETE = "delete" # マーケ用途だけのテーブル: 行ごと消す
PSEUDONYMIZE = "pseudonymize" # ⑤ 取引: 識別子と PII 列だけ外す
@dataclass(frozen=True, slots=True)
class ErasureTarget:
"""消去対象のテーブル1つ。
Attributes:
table_id: `project.dataset.table` 形式の完全修飾名。
action: 行ごと消すか、識別子だけ外すか。
key_column: 会員を特定する列名。
pii_columns: pseudonymize のとき NULL にする列。
"""
table_id: str
action: ErasureAction
key_column: str
pii_columns: tuple[str, ...] = ()
def load_targets(manifest_path: Path) -> list[ErasureTarget]:
"""dbt の manifest.json から meta.erasure を持つノードを列挙する。
Args:
manifest_path: `dbt parse` が出力した manifest.json のパス。
Returns:
消去対象テーブルのリスト(model と snapshot の両方)。
"""
manifest = json.loads(manifest_path.read_text())
targets: list[ErasureTarget] = []
for node in manifest["nodes"].values():
# ①② incremental も snapshot も同じ宣言で拾う
if node["resource_type"] not in ("model", "snapshot"):
continue
spec = node["config"].get("meta", {}).get("erasure")
if spec is None:
continue
targets.append(ErasureTarget(
table_id=f"{node['database']}.{node['schema']}.{node['alias']}",
action=ErasureAction(spec["action"]),
key_column=spec["key"],
pii_columns=tuple(spec.get("pii_columns", [])),
))
return targets
def build_dml(target: ErasureTarget) -> str:
"""1テーブル分の DML。会員IDは SQL に埋め込まず台帳を副問い合わせで引く。"""
# ⑦ 1バッチ1文。⑥ 値を文字列連結しない
in_batch = f"(SELECT member_id FROM `{LEDGER}` WHERE batch_id = @batch_id)"
if target.action is ErasureAction.DELETE:
return f"DELETE FROM `{target.table_id}` WHERE {target.key_column} IN {in_batch}"
# ⑤ 金額・日付は残す → 報告済みの売上が変わらない
sets = [f"{target.key_column} = '{ERASED_MEMBER_ID}'"]
sets += [f"{col} = NULL" for col in target.pii_columns]
return (f"UPDATE `{target.table_id}` SET {', '.join(sets)} "
f"WHERE {target.key_column} IN {in_batch}")
def claim_batch(client: bigquery.Client, now: dt.datetime) -> str | None:
"""受付から十分時間が経った請求に batch_id を振る。対象が無ければ None。"""
batch_id = uuid.uuid4().hex
job = client.query(
f"""UPDATE `{LEDGER}` SET batch_id = @batch_id
WHERE status = 'requested' AND batch_id IS NULL
AND requested_at <= @cutoff""",
job_config=bigquery.QueryJobConfig(query_parameters=[
bigquery.ScalarQueryParameter("batch_id", "STRING", batch_id),
bigquery.ScalarQueryParameter(
"cutoff", "TIMESTAMP", now - STREAMING_BUFFER_GRACE),
]),
)
job.result()
return batch_id if job.num_dml_affected_rows else None
def propagate(client: bigquery.Client, targets: list[ErasureTarget],
now: dt.datetime) -> None:
"""1バッチ分の消去を全対象へ伝播させ、台帳を propagated に進める。
同じ batch_id で再実行しても影響行数が 0 になるだけなので冪等。
Argo の retryStrategy でそのまま再試行してよい。
Raises:
google.api_core.exceptions.GoogleAPICallError: DML が失敗したとき。
"""
batch_id = claim_batch(client, now)
if batch_id is None:
return
params = [bigquery.ScalarQueryParameter("batch_id", "STRING", batch_id)]
audit_rows = []
for target in targets:
job = client.query(build_dml(target),
job_config=bigquery.QueryJobConfig(query_parameters=params))
job.result() # ⑥ 失敗はここで例外。握り潰さない
audit_rows.append({
"batch_id": batch_id, "table_id": target.table_id,
"action": target.action.value,
"affected_rows": job.num_dml_affected_rows or 0,
"job_id": job.job_id, "executed_at": now.isoformat(),
})
if errors := client.insert_rows_json(AUDIT_LOG, audit_rows):
raise RuntimeError(f"監査ログの書き込みに失敗: {errors}")
# ③ 全テーブル成功後に台帳を進める。通知はまだしない
client.query(
f"""UPDATE `{LEDGER}`
SET status = 'propagated', propagated_at = @now,
erasable_after = TIMESTAMP_ADD(@now, INTERVAL {PHYSICAL_ERASURE_DAYS} DAY)
WHERE batch_id = @batch_id""",
job_config=bigquery.QueryJobConfig(query_parameters=params + [
bigquery.ScalarQueryParameter("now", "TIMESTAMP", now)]),
).result()
physically_erased への遷移と本人通知は別ステップ(erasable_after <= CURRENT_TIMESTAMP() の行だけが対象)。# raw.members と fct_orders だけ
# mart_birthday_coupon_targets(incremental)→ 漏れ ①
# snap_members(履歴に email/phone)→ 漏れ ②
snapshots:
- name: snap_members
config:
hard_deletes: new_record # 削除を「記録」するだけ
models:
- name: int_member_profile
config:
meta:
erasure: {action: delete, key: member_id}
- name: mart_birthday_coupon_targets
config:
meta:
erasure: {action: delete, key: member_id} # ①
- name: fct_orders
config:
meta:
# ⑤ 金額は残し、識別子と PII 列だけ外す
erasure: {action: pseudonymize, key: member_id,
pii_columns: [shipping_zip, shipping_pref]}
snapshots:
- name: snap_members
relation: ref('stg_members')
config:
unique_key: member_id
strategy: check
check_cols: [email, phone, birthday, prefecture]
hard_deletes: new_record
meta:
erasure: {action: delete, key: member_id} # ② 履歴行ごと
-- erasure_requests.status を 'done' にするだけ(証跡なし)
-- raw / mart の time travel は既定 7 日
CREATE SNAPSHOT TABLE `mops-project.backup.members_{{ ds_nodash }}`
CLONE `mops-project.raw.members`;
-- 有効期限なし → 消去済み会員が永久に残る ④
-- governance データセットは PII を持たない
CREATE TABLE IF NOT EXISTS `mops-project.governance.erasure_ledger` (
member_id STRING NOT NULL,
requested_at TIMESTAMP NOT NULL,
batch_id STRING,
status STRING NOT NULL, -- requested/propagated/physically_erased/notified
propagated_at TIMESTAMP,
erasable_after TIMESTAMP -- ③ この時刻以降に「完了」と言ってよい
);
CREATE TABLE IF NOT EXISTS `mops-project.governance.erasure_audit_log` (
batch_id STRING NOT NULL, table_id STRING NOT NULL, action STRING NOT NULL,
affected_rows INT64 NOT NULL,
job_id STRING NOT NULL, -- INFORMATION_SCHEMA.JOBS で後から追える
executed_at TIMESTAMP NOT NULL
);
-- ③ PII データセットの time travel を最小の 48 時間へ
ALTER SCHEMA `mops-project.raw` SET OPTIONS (max_time_travel_hours = 48);
ALTER SCHEMA `mops-project.mart` SET OPTIONS (max_time_travel_hours = 48);
CREATE SNAPSHOT TABLE `mops-project.backup.members_{{ ds_nodash }}`
CLONE `mops-project.raw.members`
OPTIONS (
-- ④ TABLE_SNAPSHOT_TTL_DAYS と一致。延ばすと SLA が延びる
expiration_timestamp = TIMESTAMP_ADD(CURRENT_TIMESTAMP(), INTERVAL 7 DAY)
);
-- meta.erasure を持つ全モデル・スナップショットで、伝播済みの会員IDが 0 件であること
{% set checks = [] %}
{% for node in graph.nodes.values()
if node.resource_type in ['model', 'snapshot'] and node.config.meta.get('erasure') %}
{% do checks.append(
"SELECT '" ~ node.name ~ "' AS model, COUNT(*) AS leaked FROM " ~ node.relation_name
~ " WHERE " ~ node.config.meta.erasure.key ~ " IN (SELECT member_id FROM `"
~ "mops-project.governance.erasure_ledger` WHERE status != 'requested')") %}
{% endfor %}
SELECT * FROM ({{ checks | join(' UNION ALL ') }}) WHERE leaked > 0
# PII らしい列を持つのに meta.erasure が無いノードがあれば CI を落とす
PII_COLUMN_NAMES = frozenset({"member_id", "email", "phone", "birthday"})
| 問題 | 修正 | 効果 |
|---|---|---|
| ① incremental に伝播しない | meta.erasure 付き全ノードへ DELETE | クーポン配信対象から消える |
| ② snapshot 履歴に PII | snapshot も宣言し履歴行ごと DELETE | email/phone の過去版も消える |
| ③ 即日「完了」通知 | time travel 48h、erasable_after 後に通知 | 通知内容が事実と一致 |
| ④ 期限なし table snapshot | expiration 7日 | 退避コピーも期限内に消える |
| ⑤ 取引行ごと削除 | member_id を ERASED に置換 | 報告済みの売上が変わらない |
| ⑥ 握り潰し・部分消去 | 2時間猶予+例外で停止+冪等な再実行 | 途中状態で「完了」にならない |
| ⑦ 棚卸し・検証なし/DML 浪費 | 1テーブル1文+dbt test+CI検査 | スキャン約120TB/日 → 約1.2TB/日(導出) |
実行例(input → output)
input: 9/1 10:00 に会員 m_001 が消去請求。fct_orders に8月の注文3件(計12,800円)、snap_members に履歴4行、mart_birthday_coupon_targets に1行、8/31 の table snapshot に1行。
| 工程 | Bad | Good |
|---|---|---|
| 9/2 02:00 バッチ | raw 1行・fct_orders 3行を DELETE。mart と snapshot は残る | 受付から16時間経過でバッチ入り。DELETE 1+4+1行、fct_orders 3行を ERASED に UPDATE |
| 8月売上 | 12,800円減る | 変わらない |
| クーポン配信 | 9/21 に届く | 届かない |
| dbt test | 無し | assert_erased_members_absent 0行で合格 |
| 本人通知 | 9/1「完了」(time travel で読める) | erasable_after = 9/11 以降に通知 |
| 8/31 の table snapshot | 無期限に残る | 9/7 に期限切れ |
消去完了 SLA の導出
| 区間 | 日数 | 出所 |
|---|---|---|
| 受付 → 伝播(毎晩1回+2時間猶予) | 最大1日 | 導出 |
| 伝播 → time travel 終了 | 2日 | 設定値 max_time_travel_hours=48(最小値) |
| time travel → fail-safe 終了 | 7日 | 公式ドキュメントの固定値 |
| table snapshot の最長残存 | 7日(上に包含) | 設定値 |
| 合計 | 最大10日 | 導出 |
本人・問い合わせ窓口には 「受付から14日以内」 と約束する(余裕4日は再実行・休日対応のための仮定)。
member_id でのクラスタリング追加は dry run の totalBytesProcessed で効果を見てから判断する。迷った案と採らなかった理由
| 案 | 長所 | 採らなかった理由 | 前提が変わったら |
|---|---|---|---|
| A. 毎晩 full-refresh | 伝播ロジック不要 | スキャン量が桁違い。snapshot は full-refresh で履歴を失う。time travel・退避コピーは解決しない | mart が小さく snapshot 不使用なら最も単純で正しい |
| B. 暗号学的消去(会員ごとの鍵で AEAD.ENCRYPT、鍵行を削除) | DML できない複製も鍵1行の削除で読めなくなる | 全 PII 列の暗号化移行が大工事。JOIN のたびに復号。鍵テーブル自体の time travel・fail-safe が残り完了日は縮まらない | GCS エクスポートや外部共有など「DML できない複製」が増えたら第一候補 |
| C. 取引行も削除(Bad のまま) | 「全部消した」と言いやすい | 金額は個人データではなく、報告済み数値が変わる | BigQuery が取引の原本(法定保存7年)なら削除も仮名化も不可。利用停止に切り替え、保存期間満了後に消去 |
| D. time travel 7日のまま | 誤った dbt run からの復旧に7日使える | 完了まで 1+7+7=15日で約束の14日を超える | 復旧手段を7日TTLの table snapshot に寄せたので48hで足りる。snapshot を止めるなら再検討 |
ポイント解説
invalidate も new_record も削除を記録する設定。snapshot は履歴を残す装置なので、消去は別工程。実務への応用
- PII を持つのは会員マスタだけではない。クーポン配信ログ・A/B テスト割付表・レコメンド特徴量も対象候補。まず
INFORMATION_SCHEMA.COLUMNSでmember_id/email列を持つテーブルを全件出し、meta の有無と突き合わせる erasure_audit_logは法務・個人情報窓口への証跡になる。問い合わせ対応が「エンジニアに聞く」から「ダッシュボードを見る」に変わる- action に
suppress(配信用 mart だけ対象)を足せば、退会しない会員のオプトアウト(利用停止)にも流用できる
扱わなかった範囲(と理由)
- 学習済みモデル(BigQuery ML・Vertex AI)からの影響除去 — 再学習周期の設計問題で、今日の主題(複製の消去)とは別。発展問題へ
- BI キャッシュ・CRM など SaaS 側 — 分析基盤の外。台帳の status を外部に配る連携の問題
- Cloud Logging に出た PII — ログバケットの保持日数で扱うべきで、DML の対象ではない
- GDPR(EU) — 越境事業開始時に、1か月以内の応答義務(第12条3項)を SLA 表に加えて再計算する
一次資料(参照日 2026-09-23)
- BigQuery: Data retention with time travel and fail-safe
- BigQuery: Introduction to table snapshots
- BigQuery: AEAD encryption concepts
- BigQuery: Streaming data(legacy)— バッファ内の行と DML
- dbt: Snapshots(hard_deletes) / dbt: incremental strategy
- 個人情報の保護に関する法律 — 第22条「利用する必要がなくなったときは、当該個人データを遅滞なく消去するよう努めなければならない」、第35条(利用停止等)
- 個人情報保護委員会 ガイドライン(通則編) / GDPR 第17条・第12条3項
今日のまとめ
次のステップ
- 発展問題: レコメンド用特徴量テーブルとそれで学習した BigQuery ML モデルがある。消去請求を受けた会員の影響をモデルから取り除く必要があるか、あるなら再学習周期をどう決め、「14日以内」の約束とどう整合させるか設計せよ。
- 参考: BigQuery time travel / fail-safe / table snapshots / AEAD 暗号化関数 / dbt snapshots hard_deletes / dbt manifest と graph 変数 / 個人情報保護法 第22条・第35条