C データエンジニアリング — 退会会員の個人データ消去を「分析基盤の下流まで」伝播させる(incremental mergeは上流の削除を下流に伝えない × dbt snapshotのhard_deletesは履歴行のPIIを残す × time travel 7日+fail-safe 7日を無視した即日「完了」通知 × 有効期限なしtable snapshotにPIIが永久残存 × 取引行を行ごと削除し報告済み売上が38万円変動 × 例外握り潰しとストリーミングバッファ起因の部分消去 × 対象テーブルの手書きリストと検証なし・1件ずつDMLで1.2TBを100回スキャン)(MOps 販促システムチーム member-erasure-job Bad→Good)

2026-09-23 水曜 水曜 C: データエンジニアリング ★★★★☆ BigQuery / dbt Core 1.9+ / Python 3.13 time travel / fail-safe / table snapshot / dbt meta

概要

🧬

消去は「全ての複製」が対象

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件が同時に起きた。

  1. 消去受付から20日後の元会員に誕生日クーポンのメールが届いた(本人には受付当日に「消去が完了しました」と通知済み)
  2. 消去処理の翌朝、経営会議に報告済みの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)

この実装には 7つの問題 が隠れています。見つけてから先へ進んでください。
member_erasure_job.py — 1件ずつ DELETE・握り潰し
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件失敗しても全体は止めない
snapshot / incremental mart / 退避コピー
# 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`;
問題点サマリー(7点)
1-2dbt の派生テーブル — incremental に伝播しない / snapshot 履歴に PII 残存
3-4BigQuery と退避コピー — time travel・fail-safe を無視した即日完了 / 期限なし table snapshot
5-7消去のやり方と運用 — 取引行ごと削除 / 握り潰し・部分消去 / 棚卸し・検証なしで DML 浪費

ヒント(段階的開示)

ヒント1 — 方向性
「消去」は1テーブルの DELETE ではなく、データが複製された全ての場所から、復元できない状態になるまでの工程である。複製は3種類ある。(a) dbt が作る派生テーブル(incremental・snapshot)、(b) BigQuery 自身が持つ過去版(time travel・fail-safe)、(c) 人が作った退避コピー(table snapshot・GCS エクスポート)。それぞれ「消える条件」が違う。
ヒント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
2snapshot 履歴行に 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)

① 会員データの複製マップ(どこに何が残るか) raw.members insertAll で書込(バッファあり) (a) dbt 派生テーブル — DML で消せる int_member_profile mart_birthday_coupon incremental merge snap_members 履歴行に email/phone fct_orders 金額は残す=仮名化 対象は meta.erasure で宣言 → manifest から列挙 (b) BigQuery の過去版 time travel 既定7日 → 48hへ短縮 fail-safe 固定7日・利用者は操作不可 待つしかない (c) 退避コピー table snapshot 読み取り専用・DML不可 消えるのは 有効期限だけ 期限 7日を付与 ② 受付から「復元不能」までのタイムライン(会員 m_001、9/1 受付) 9/19/29/4 9/79/109/11 9/139/159/17 Bad time travel 7日 time travel(FOR SYSTEM_TIME AS OF で読める) fail-safe(サポート経由で戻せる)→ 9/15 9/1「完了」と通知 ✗ mart / snapshot 履歴 / 期限なし table snapshot に残り続ける → 9/21 クーポン配信 Good time travel 48h 待機 9/2 全複製へ伝播 TT 2日 fail-safe 7日 8/31 table snapshot(期限 9/7 で消滅) 9/11 erasable_after → ここで通知 9/15 約束の期限(14日) 完了日 = max(伝播1日 + time travel 2日 + fail-safe 7日, table snapshot 期限) = 最大10日 → 余裕4日を足して「14日以内」と約束

上段: 複製は3種類で、消える条件がそれぞれ違う。下段: Good では「一番遅く消える複製」から完了日を逆算して通知する。

模範解答

黄色 = 設計意図のコメント、緑 = Bad の問題番号を直した箇所。

Before — 1件ずつ・2テーブル直書き・握り潰し
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  # ⑥ 握り潰し
After — manifest から列挙・1テーブル1文・完了日を逆算
"""退会会員の個人データ消去を、分析基盤の下流まで伝播させるジョブ。

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() の行だけが対象)。
Before — 対象はコードに2テーブル直書き
# raw.members と fct_orders だけ
# mart_birthday_coupon_targets(incremental)→ 漏れ ①
# snap_members(履歴に email/phone)→ 漏れ ②

snapshots:
  - name: snap_members
    config:
      hard_deletes: new_record  # 削除を「記録」するだけ
After — dbt 側で宣言(棚卸しの単一の真実源)
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}  # ② 履歴行ごと
Before — 台帳なし・time travel 既定・snapshot 期限なし
-- erasure_requests.status を 'done' にするだけ(証跡なし)
-- raw / mart の time travel は既定 7 日

CREATE SNAPSHOT TABLE `mops-project.backup.members_{{ ds_nodash }}`
CLONE `mops-project.raw.members`;
-- 有効期限なし → 消去済み会員が永久に残る ④
After — 台帳・監査ログ・保持期間
-- 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)
);
tests/assert_erased_members_absent.sql(⑦ 消去後ゼロ件)
-- 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
ci/check_pii_meta.py の要点(新モデル追加時の漏れを止める)
# PII らしい列を持つのに meta.erasure が無いノードがあれば CI を落とす
PII_COLUMN_NAMES = frozenset({"member_id", "email", "phone", "birthday"})
問題修正効果
① incremental に伝播しないmeta.erasure 付き全ノードへ DELETEクーポン配信対象から消える
② snapshot 履歴に PIIsnapshot も宣言し履歴行ごと DELETEemail/phone の過去版も消える
③ 即日「完了」通知time travel 48h、erasable_after 後に通知通知内容が事実と一致
④ 期限なし table snapshotexpiration 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行。

工程BadGood
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日は再実行・休日対応のための仮定)。

コスト(導出、dry run で要実測): Bad は1日100件 × 1.2TB ≒ 120TB/日、Good は1テーブル1文で 1.2TB/日。仮に $6.25/TiB(US マルチリージョンの公開オンデマンド単価。東京は異なる)とすると 約$680/日 → 約$6.8/日。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 を止めるなら再検討

ポイント解説

1incremental merge は「消えた」を伝えない — 来た行を upsert するだけ。削除を伝えるには明示的な DELETE か full-refresh しかない。
2snapshot の hard_deletes は「消す」設定ではない — invalidate も new_record も削除を記録する設定。snapshot は履歴を残す装置なので、消去は別工程。
3BigQuery の「消えた」は3段階 — DML 直後 → time travel 終了 → fail-safe 終了。「復元不能」は3段目。
4識別子と事実を分ける — 「いつ・いくら」は残し「誰が」だけ外す。消去と経営指標の安定(9/16 の教訓)が両立する。
5棚卸しは宣言から生成する — 宣言(meta)・実行(manifest から列挙)・検証(dbt test / CI)が同じ情報源を見ている。

実務への応用

  • 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)

今日のまとめ

消去は「1テーブルの DELETE」ではなく「全ての複製が復元不能になるまで」の工程であり、複製ごとに消える条件(DML・有効期限・time travel 窓・fail-safe)が違う。 対象は dbt の meta に宣言して機械的に棚卸しし、取引は識別子だけを外し、完了日は一番遅く消える複製から逆算して約束する。

次のステップ

  • 発展問題: レコメンド用特徴量テーブルとそれで学習した BigQuery ML モデルがある。消去請求を受けた会員の影響をモデルから取り除く必要があるか、あるなら再学習周期をどう決め、「14日以内」の約束とどう整合させるか設計せよ。
  • 参考: BigQuery time travel / fail-safe / table snapshots / AEAD 暗号化関数 / dbt snapshots hard_deletes / dbt manifest と graph 変数 / 個人情報保護法 第22条・第35条

自己評価(あとで記入)