Microsoft Purview データマップで削除できないアセットをREST APIで削除する方法(429/408エラー対策)

Microsoft Purview のデータマップで、コレクションを削除したいのに最後の1アセットだけ 429→408 で開けず削除できない――この“消せないアセット”を REST API で安全に削除し、同じ沼にハマらないための実務手順をまとめます。

目次

結論:UIで削除できないアセットは REST API「Delete with Hierarchy」で“ジョブ削除”する

Purview ポータル(UI)でアセットを選択した瞬間に 429 (Too Many Requests) → 408 (Request Timeout) が出てしまう場合、画面側の再試行や表示処理がボトルネックになっていることがあります。こういうときは、UI にこだわらず Data Map の REST API で削除処理を実行するのが現実的です。

特に、階層(子アセット)を含む削除が必要なケースは、非同期ジョブとして動く Delete with Hierarchy がハマりやすいです。

優先度やること狙いポイント
高Delete with Hierarchy を API で実行UI を迂回して削除を通す202 Accepted と Operation-Location を受け取ったら「成功」ではなく「ジョブ開始」です
中参照関係(relationship)や所属コレクションを API で確認削除ブロック要因を潰すEntity Get の relationshipAttributes を確認して“残リンク”を疑う
中429/408 を想定したリトライ(指数バックオフ)を実装スロットリング/タイムアウトを回避短時間に連打しない。Retry-After があれば最優先
低MoveTo API で“別コレクションへ退避”先にコレクション削除を通す削除が詰む場合の逃げ道。後で退避先で整理する
最終手段Microsoft サポートへエスカレーションバックエンドでの不整合を解消GUID、リクエストID、失敗期間、相関IDを揃える

まず押さえる:コレクションが削除できる条件と「最後の1アセット」が邪魔をする理由

コレクション削除は、見た目以上に“紐づき”のチェックが厳密です。コレクションは 子コレクション・アセット・データソース・スキャン が関連付いていると削除できません。つまり「配下のアセットを全部消したはず」でも、何か1つでも残っていればコレクション削除が止まります。

さらにややこしいのが、Data Map(内部的には Atlas 系のデータモデル)の世界では エンティティの状態が ACTIVE / DELETED で管理され、“削除=即座に完全消滅”とは限らないことです。ドキュメント上も「Deleted entities are not removed」と明記されており、削除後も内部上は状態管理が残る設計になっています。

このため、UI 側で表示しようとしたときに、何らかの不整合(参照が壊れている、索引が追いついていない、関連が過剰に多いなど)があると、表示・削除の入口で 429/408 を誘発しやすくなります。

429 と 408 を正しく読む:原因の当たりを付ける(現場向け)

HTTP意味Purview で起こりがちな状況現場での対処方針
429Too Many Requests(スロットリング)同種の操作を短時間に連打/背後で大量の参照取得が走る/削除対象が重いリトライ間隔を伸ばす、並列数を下げる、実行時間帯をずらす
408Request Timeout(要求がタイムアウト)階層が深い(例:ストレージ配下の大量パス)/関連オブジェクトが多い/処理が長い非同期ジョブ型 API を使う、タイムアウト前提で再試行、参照を整理してから再実行

今回のように 「429 → 408」 の並びで出るケースは、単純に“混んでいる”だけでなく、アセットの参照・階層処理が重すぎて、画面が耐えられていないパターンが多いです。2週間様子見で変化がないなら、UI の回復を待つより「API で片付ける」が最短です。

実務ステップ:Delete with Hierarchy で“消せないアセット”を削除する

全体の流れ

  1. 削除対象アセットの GUID を特定する
  2. Entra ID(旧 Azure AD)で Purview 用の Bearer トークンを取得する
  3. Delete with Hierarchy を実行し、Operation-Location を受け取る
  4. Operation Status をポーリングして成功/失敗を確認する
  5. 失敗なら error.message / requestId を回収し、参照整理→再実行、またはサポートへ

準備:必要な情報チェック表

項目例どこで使うメモ
Purview アカウント名contoso-purviewAPI のベース URL例:https://{accountName}.purview.azure.com
削除したいアセット GUIDxxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxxDelete with Hierarchy の guid パラメータUI が開けなくても Search API で拾えることがあります
Tenant IDxxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxxトークン取得Entra ID のテナント
Client ID / Secret(アプリ登録)トークン取得自動化するならサービスプリンシパル推奨
権限Data Curator など削除実行削除操作には Data Curator が必要です

ステップ1:GUID を特定する(UI が 429/408 で開けないときの現実解)

UI でアセット詳細が開けない場合、最初の壁が「GUID が分からない」です。ここは Search API で拾うのが手堅いです。

Purview Data Map の Search API は、次のように POST {endpoint}/datamap/api/search/query で検索できます。

「コレクション配下のアセットを全部見たい」場合は、filter に collectionId を指定する例が用意されています。これで“最後の1件”が何者かを API 側から洗い出せます。

{
  "keywords": null,
  "limit": 1000,
  "filter": {
    "collectionId": "YOUR_COLLECTION_ID_OR_NAME"
  }
}

返ってくる value の各要素に id(= GUID)が含まれるので、消せないアセットの GUID をここで確保します。

コツ:残骸が1件だけなら、collectionId で一覧化 → その GUID を Delete with Hierarchy に流す、が最短ルートです。UI の「選択した瞬間に落ちる」を避けられます。

ステップ2:Purview 用の Bearer トークンを取得する(audience 間違いが多い)

API を叩くには Entra ID のアクセストークンが必要です。自動化・再試行を考えると、サービスプリンシパル(アプリ登録)で client_credentials を使うのが扱いやすいです。公式の手順では、トークン取得先は /oauth2/token、resource は https://purview.azure.net になっています。

POST https://login.microsoftonline.com/YOUR_TENANT_ID/oauth2/token
Content-Type: application/x-www-form-urlencoded

client_id=YOUR_CLIENT_ID
&client_secret=YOUR_CLIENT_SECRET
&grant_type=client_credentials
&resource=https://purview.azure.net

また、Data Map の API を呼ぶには、サービスプリンシパル(またはユーザー)に適切なロール付与が必要です。公式の例では Data Map / Catalog のデータプレーンにアクセスするために Data Curator などの割り当てを案内しています。

なお、トークンの “audience” を間違えると Invalid token audience で弾かれます。よくあるのが https://management.core.windows.net/(ARM 系)を取りに行ってしまうケースで、Purview 側は https://purview.azure.net が有効な audience として挙がります。

ステップ3:Delete with Hierarchy を実行する(202 Accepted を確認)

削除の本体です。Delete with Hierarchy は次の形式で呼び出します。レスポンスは 202 Accepted で、Operation-Location ヘッダーが返ります。

DELETE https://YOUR_ACCOUNT_NAME.purview.azure.com/datamap/api/entity/bulk/delete?guid=YOUR_ASSET_GUID&api-version=2023-10-01-preview

オプションで parallelCount を付けられます。スロットリング(429)が出る環境では、むやみに並列を上げると逆効果になりやすいので、まずは控えめ(例:4)から始めるのが安全です。

DELETE https://YOUR_ACCOUNT_NAME.purview.azure.com/datamap/api/entity/bulk/delete?guid=YOUR_ASSET_GUID&parallelCount=PARALLEL_COUNT_4&api-version=2023-10-01-preview

成功時は 202 と合わせて、次のような Operation-Location(操作状況を取得する URL)が返ります。ここが取れたら「削除ジョブは開始した」と判断できます。

ステップ4:Operation Status をポーリングして削除完了を確認する

非同期ジョブの状態確認は Operation Status で行います。公式の Operation Status 取得 API は次の形です。

GET https://YOUR_ACCOUNT_NAME.purview.azure.com/datamap/api/entity/operations/OPERATION_ID?api-version=2023-10-01-preview

ただし Delete with Hierarchy のレスポンスで返る Operation-Location は /catalog/api/entity/operations/... になっている例があるため、基本はヘッダーで返ってきた Operation-Location をそのまま叩くのが事故りません。

ステータスは NotStarted / Running / Succeeded / Failed が定義されています。Succeeded になれば削除処理は完了です。Failed の場合は error.message と requestId を回収して次の手に繋げます。

すぐ使える:429/408 前提の Python 実行例(指数バックオフ+ジョブ監視)

Insomnia / Postman でも良いのですが、429/408 が絡むと手動は消耗戦になりがちです。指数バックオフと Operation Status ポーリングを組み込んだ小さなスクリプトを用意しておくと、同じ現象が起きても短時間で片付けられます。

import os
import time
import random
import requests

TENANT_ID = os.environ["TENANT_ID"]
CLIENT_ID = os.environ["CLIENT_ID"]
CLIENT_SECRET = os.environ["CLIENT_SECRET"]
PURVIEW_ACCOUNT = os.environ["PURVIEW_ACCOUNT"]   # 例: contoso-purview
ASSET_GUID = os.environ["ASSET_GUID"]             # 削除したい GUID
API_VERSION = "2023-10-01-preview"

BASE = f"https://{PURVIEW_ACCOUNT}.purview.azure.com"

def get_token() -> str:
    url = f"https://login.microsoftonline.com/{TENANT_ID}/oauth2/token"
    data = {
        "client_id": CLIENT_ID,
        "client_secret": CLIENT_SECRET,
        "grant_type": "client_credentials",
        "resource": "https://purview.azure.net",
    }
    r = requests.post(url, data=data, timeout=30)
    r.raise_for_status()
    return r.json()["access_token"]

def request_with_backoff(call, max_attempts=8, base_delay=2, max_delay=90):
    """
    429/408/5xx を“想定内”として扱い、指数バックオフ + ジッターで再試行する。
    Retry-After ヘッダーがあれば優先する。
    """
    for attempt in range(1, max_attempts + 1):
        resp = None
        try:
            resp = call()
            if resp.status_code in (429, 408, 500, 502, 503, 504):
                ra = resp.headers.get("Retry-After")
                if ra:
                    sleep = float(ra)
                else:
                    sleep = min(max_delay, base_delay * (2 ** (attempt - 1)))
                sleep += random.uniform(0, 1.0)
                time.sleep(sleep)
                continue
            resp.raise_for_status()
            return resp
        except requests.RequestException:
            if attempt == max_attempts:
                # 失敗時は最後のレスポンス本文も追えるようにしておくと調査が楽
                if resp is not None:
                    raise RuntimeError(f"HTTP {resp.status_code}: {resp.text}") from None
                raise
            sleep = min(max_delay, base_delay * (2 ** (attempt - 1)))
            sleep += random.uniform(0, 1.0)
            time.sleep(sleep)

def main():
    token = get_token()
    headers = {"Authorization": f"Bearer {token}"}

    # Delete with Hierarchy(ジョブ開始)
    def delete_call():
        url = f"{BASE}/datamap/api/entity/bulk/delete"
        params = {"guid": ASSET_GUID, "api-version": API_VERSION}
        return requests.delete(url, headers=headers, params=params, timeout=30)

    r = request_with_backoff(delete_call)
    if r.status_code != 202:
        raise RuntimeError(f"Unexpected status: {r.status_code} body={r.text}")

    op_location = r.headers.get("Operation-Location")
    if not op_location:
        raise RuntimeError("Operation-Location header is missing.")
    print("Operation-Location:", op_location)

    # Operation Status をポーリング
    while True:
        def status_call():
            return requests.get(op_location, headers=headers, timeout=30)

        s = request_with_backoff(status_call)
        data = s.json()
        status = data.get("status")
        print("status:", status)

        if status in ("Succeeded", "Failed"):
            print("final:", data)
            if status == "Failed":
                # error.message をログに残し、参照整理 or サポート問い合わせへ
                err = data.get("error", {})
                raise RuntimeError(f"Delete job failed. code={err.get('code')} message={err.get('message')}")
            break

        time.sleep(10)

if __name__ == "__main__":
    main()

運用のコツ:スクリプトを回す前に、同じ Purview アカウントで大量の削除・更新・スキャンが走っていない時間帯を選ぶと、429 の出方が穏やかになります。ジョブ監視まで自動化しておけば、夜間に流して翌朝に結果だけ確認、という運用も取りやすいです。

削除前に必ず見る:参照関係が残ると“削除ブロック”になることがある

「Hierarchy も含めて消す」つもりでも、アセットが他エンティティから参照されていると、削除が通りにくい(または内部で失敗する)ことがあります。削除前にできる範囲で参照を把握しておくと、失敗時の切り分けが速くなります。

参照確認の基本は Entity Get です。GUID を指定してエンティティ定義を取得でき、relationshipAttributes も返ります。

GET https://YOUR_ACCOUNT_NAME.purview.azure.com/datamap/api/atlas/v2/entity/guid/YOUR_ASSET_GUID?minExtInfo=true

レスポンスの relationshipAttributes は、「どの親子関係・プロセス・スキーマ・用語付与などが付いているか」の手掛かりになります。削除対象が重いときは、まず ignoreRelationships=true で軽く情報を取り、必要に応じて詳細を取りに行く、という順番にすると 408 を踏みにくいです。

また、削除作業の効率という意味では「下の階層を1個ずつ消す」より、「上位の親アセットを削除して子をまとめて消す」方がスロットリングを避けやすいことがあります。Purview の UI でも、階層の親を削除すると子アセットもまとめて削除される動きが明記されています。

見落としがちな再発要因:スキャンが残っていると、削除しても“戻ってくる”

「やっと消せた!」のに、しばらくすると同じアセットが再び出現して混乱することがあります。これは、元データソースのスキャンが生きていると、フルスキャンで再取り込みされることがあるためです。ドキュメントにも、削除したアセットがフルスキャンで再取り込みされ得る旨が説明されています。

今回の目的が「コレクションを完全に整理して削除する」なら、アセット削除と並行して、次の点もチェックしておくと安全です。

  • 対象コレクションに紐づく データソース や スキャン が残っていないか(残っているとコレクション削除が止まる)
  • 削除後に自動スキャンが走らないよう、運用側のスケジュールを一時停止できないか
  • テスト用のソースなら、スキャンを止めたうえで削除する(“戻り”を防ぐ)

Delete with Hierarchy がどうしても通らないときの逃げ道:MoveTo API で退避してコレクション削除を先に終わらせる

Delete with Hierarchy をリトライしても失敗が続き、「とにかくコレクション削除を先に完了させたい」場合は、問題のアセットを一時的な隔離コレクションに移動して、元コレクションの紐づきを外す手があります。

移動は Entity の MoveTo API を使います。ターゲットの collectionId をクエリに指定し、body に GUID 配列を渡します。

POST https://YOUR_ACCOUNT_NAME.purview.azure.com/datamap/api/entity/moveTo?api-version=2023-09-01&collectionId=TARGET_COLLECTION_ID
Content-Type: application/json

{
  "entityGuids": [
    "YOUR_ASSET_GUID"
  ]
}

退避後に元コレクションを削除できれば、「環境整理」を止めずに前へ進めます。退避先で落ち着いて参照関係を洗ってから削除ジョブを再実行する、という段取りにすると作業が分断されません。

それでも無理なら:Microsoft サポートへ渡す情報を“最小で強く”揃える

数日〜数週間、同じ GUID に対して削除が失敗し続ける場合、バックエンドでエンティティが不整合な状態(中途半端な状態)に固定されている可能性があります。ここまで来たら、サポートへ調査依頼する方が早いです。

サポートに渡すと強い情報具体例なぜ効くか
対象アセットの GUIDxxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx内部ログを引き当てやすい
失敗した期間(日時の範囲)2025/12/01〜2025/12/15 などログ検索範囲を絞れる
API の requestId / エラーメッセージ全文Operation Status の error.message など根本原因(参照破損/スロットリング/内部例外)を特定しやすい
実行した API(URL、api-version、parallelCount)Delete with Hierarchy の実行記録再現が容易になる
Operation-Location / operationId…/catalog/api/entity/operations/xxxxxジョブ単位の追跡ができる

「UI で 429/408」「API でも同じ GUID だけ失敗が継続」という状況は、再試行だけで自然回復することもありますが、2週間変化がないなら、ログ調査を依頼して“固定化”を解除してもらう判断は合理的です。

まとめ:消せないアセットの片付けは“API を主戦場”にすると早い

  • コレクション削除は「関連がゼロ」が条件。最後の1アセットが残ると削除が止まる
  • UI で 429/408 が続くなら、REST API の Delete with Hierarchy を試す
  • 202 Accepted と Operation-Location を受け取ったら、Operation Status で成功/失敗を確認する
  • Search API(collectionId フィルタ)で GUID を拾い、UI を迂回して削除する
  • 参照関係は Entity Get の relationshipAttributes で当たりを付ける
  • 削除後にスキャンで再取り込みされないよう、運用(スキャン/ソース)も合わせて整理する

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次