0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Power BI executeQueries で生データを取り出す — API の制約とページング設計

0
Posted at

この記事は archiningen.com からの転載です。
連載「Power BI Embedded 実践」の第 2 回 (全 3 回) です。

executeQueries で無音のデータ欠落が起きる 3 パターンと対策を示した図。CASE 1 は上限超過でも HTTP 200 が返り、応答 body の中に error が埋まったまま途中の行までしか書き込まれない切り詰め — 対策は error を top-level / results / tables の 3 箇所で検査すること。CASE 2 は 100k 行のページが続く途中で 62k 行の端数ページが返り、それを終端と誤認して以降のページを破棄してしまう — 対策は終端判定を 0 行のみとし、offset を実際に取得できた行数で進めること。CASE 3 はページ 1 に A・B・C の 3 列があるのにページ 2 では null 落ちで B 列が消え、列集合がページ間でぶれる — 対策は includeNulls: true を明示すること。図の下部に「欠落を防げない仕様なら、必ず検出できるループにする」というキャプションがある。

前回の記事では、Managed Identity によるシークレットレス構成で Power BI のレポートをアプリに埋め込むところまでを組みました。末尾で予告した「REST API でセマンティックモデルから生データを取り出す話」の回収が本記事です。埋め込みが「見せる」ための API だとすれば、今回は「取り出す」ための API の話になります。

レポートを埋め込むと、次に来る要件はだいたい決まっています。「この画面の元データを CSV でください」です。Power BI REST API の executeQueries は、セマンティックモデルに DAX クエリを POST して結果を JSON で受け取れるエンドポイントで、この要件に対する最小依存の答えになります。

ただしこの API、制約がかなり多く、しかも最大の罠は失敗が失敗の顔をしていないことです。取得上限を超えると、エラーステータスではなく HTTP 200 が返り、結果は無言で切り詰められますres.ok だけを見る素朴な実装は、欠損したデータを「正常」として出力します。データエクスポートという用途でこれは致命的です。

本記事では、筆者が実環境で executeQueries を運用して確認した制約を公式仕様と突き合わせ、データ欠落を仕組みで検出できるページング設計を Node.js で組みます。認証は前回の Managed Identity 構成をそのまま流用します。取り出した行を CSV/ZIP としてストリーミング配信する実装 (backpressure や archiver の地雷) は続編に譲り、RLS 付きモデルも対象外とします — そもそもサービスプリンシパルでは RLS 付きモデルに executeQueries を使えません (制約の章で触れます)。

executeQueries とは — 埋め込みの隣にある生データ経路

executeQueriesPOST /v1.0/myorg/groups/{workspaceId}/datasets/{datasetId}/executeQueries に DAX クエリを送ると、結果の行が JSON で返る API です。まず動かして感触を掴みます。

# 疎通確認: テーブルの先頭 3 行を DAX で取得する
TOKEN=$(az account get-access-token \
  --resource https://analysis.windows.net/powerbi/api \
  --query accessToken --output tsv)
curl -s -X POST \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d "{\"queries\":[{\"query\":\"EVALUATE TOPN(3, '受注明細')\"}]}" \
  "https://api.powerbi.com/v1.0/myorg/groups/<workspaceId>/datasets/<datasetId>/executeQueries"

results[0].tables[0].rows に行が返ってくれば疎通は成功です。前提は 3 つあります。

前提 内容
テナント設定 「Dataset Execute Queries REST API」の有効化 (前回のサービスプリンシパル許可とは別の設定。管理ポータルの検索で探すのが早いです)
データセット権限 Read に加えて Build 権限が必要
サービスプリンシパル利用時 前回のテナント設定 + ワークスペースメンバーに加え、RLS 付き・SSO 有効のモデルでは利用不可

生データを取り出す手段は他にもあるので、位置づけを整理しておきます。

手段 形式 前提 向き
executeQueries JSON Build 権限のみ 依存最小の汎用エクスポート
executeDaxQueries (Arrow 版) Arrow IPC Premium/Fabric 容量 + XMLA 有効化 + Arrow ライブラリ。執筆時点ではプレビュー 大規模・高精度
XMLA エンドポイント TOM/ADOMD Premium/Fabric 容量 + 専用クライアントライブラリ 管理・大規模分析
レポートのエクスポート API PDF/PPTX 等 容量必須 見た目の再現

筆者が executeQueries を採用したのは、HTTP + JSON だけで完結し、追加の容量要件も専用ライブラリも不要だからです。Node.js のアプリからは fetch 1 本で呼べます。その代わり、次章の制約と正面から付き合うことになります。

制約 — 公式仕様を逐語で確認する

executeQueries の制限は公式リファレンスに明記されています。まず数字から。

制限
行数 1 クエリあたり最大 100,000 行
値の数 1 クエリあたり最大 1,000,000 値 (行数と先に達した方)
応答サイズ 1 クエリあたり最大 15MB
クエリ構造 1 コール 1 クエリ、1 クエリの結果は 1 テーブル
レート ユーザーあたり 120 リクエスト/分

「値 (values)」の上限は行数より先に効くことがあります。列数を N とすると実効行数の目安は 1,000,000 ÷ N です。

列数 実効的な最大行数
5 列 100,000 行 (行上限が先)
10 列 100,000 行
20 列 50,000 行
100 列 10,000 行

上限超過は「HTTP 200 + body のエラー」で返る

ここが本記事で一番伝えたい仕様です。公式リファレンスには、別々の箇所に 2 つのことが明記されています。ひとつは、1 クエリで複数テーブルを要求した場合、または許容行数を超えた場合に「限定的なデータ」「応答内のエラー」「成功の HTTP ステータスコード (200)」が返ること。もうひとつは、15MB を超えた場合に「処理中の行は書き切られるが、それ以降の行は書き込まれない」こと。前者が「200 なのに失敗」の直接の根拠で、後者は切り詰めがページの途中の端数行で止まる理由です。いずれもバグではなく仕様です。

さらに応答スキーマを見ると、error フィールドは top-level・results[i]results[i].tables[i] の 3 箇所に定義されています。どこに現れるかはケースによるため、1 箇所だけ見る実装ではすり抜けます。

INFO 関数が使えない — テーブル一覧を API で列挙できない

executeQueries がサポートするのは DAX クエリのみで、INFO 関数・MDX・DMV クエリは非サポートです。INFO.TABLES() を投げると 400 が返り、テナント設定でも緩和できません。つまり「モデルにどんなテーブルがあるか」をこの API 自身に聞く手段がありません。

これは設計に波及します。筆者はエクスポート対象のテーブル名を管理画面で手入力する (1 行 1 テーブル) 設計に落としました。自動列挙にこだわると、代替は 2 つとも前提が重くなります。

代替 前提 見送った理由
executeDaxQueries (Arrow 版) は INFO 対応 Premium/Fabric 容量 + XMLA + Arrow 依存。執筆時点ではプレビュー 依存とプレビュー status
Scanner API (メタデータスキャン) 管理者 API 群。Fabric 管理者によるセットアップ + 管理者用読み取り専用 API のサービスプリンシパル認証の有効化 アプリに管理者 API 相当の到達範囲を持たせたくない

テーブル名の手入力は一見泥臭いですが、「エクスポートしてよいテーブルを明示的に宣言する」という許可リストとしても機能します。

ページングは公式に「Not supported」

100,000 行を超えるテーブルは複数回に分けて取るしかありませんが、公式ドキュメントの新旧 API 比較表には Pagination: Not supported と明記されています。DAX の TOPNSKIP 関数を使えば「動く」ものの、TOPNSKIP は行の順序を保証しません。順序が保証されないままページを切ると、原理的には重複や欠落が起こりえます。

つまりこの API で大きなテーブルを取り出すことは、公式には支えられていない領域に足を踏み入れることです。それでも使うなら、「欠落が起きたら必ず検出できる」ことをループの不変条件として組み込むべきです。次章がその設計です。

設計 — 「無音のデータ欠落」を仕組みで塞ぐページング

冒頭の図に挙げたとおり、無音の欠落は 3 つの経路で起きます: (1) 200 + body error の切り詰め、(2) 端数ページの終端誤認、(3) null 落ちによる列集合のぶれ。以下の設計はこの 3 つをすべて検出可能にします。

DAX は TOPNSKIP で切り出す

EVALUATE TOPNSKIP(100000, <offset>, '受注明細')

第 1 引数が取得行数、第 2 引数が読み飛ばし行数です。テーブル名はシングルクォートで囲むため、日本語のテーブル名・列名もそのまま通ります (実環境で確認済み)。

終端は「0 行」だけ、offset は実取得行数で進める

ページングループの本体です。不変条件は 2 つ — 終端判定は「0 行が返ったとき」だけ、offset は「実際に取得できた行数」でだけ進める。

// 終端は 0 行のみ。offset は実際に取得できた行数でだけ進める
let offset = 0;
const all = [];
while (true) {
  const rows = await runQuery(
    `EVALUATE TOPNSKIP(${PAGE_SIZE}, ${offset}, '${tableName}')`);
  if (rows.length === 0) break;
  all.push(...rows);
  offset += rows.length;
}

直感的には rows.length < PAGE_SIZE を終端とみなしたくなりますが、これが図の CASE 2 です。15MB 制限による切り詰めはページの途中で端数の行数を返すため、端数 = テーブルの終わりと誤認すると、残りの全ページを黙って捨てることになります。同じ理由で offset += PAGE_SIZE も危険です。切り詰めで 62,000 行しか返っていないのに offset を 100,000 進めれば、差分の 38,000 行が欠落します。実取得行数で進めていれば、切り詰められたページの続きは次のループが自然に回収します。

エラーは 3 箇所すべて検査する

// 200 応答でも error は 3 箇所に現れうる — すべて辿る
function pickError(body) {
  return body?.error
    ?? body?.results?.[0]?.error
    ?? body?.results?.[0]?.tables?.[0]?.error
    ?? null;
}

runQuery の中でこの検査を挟み、error があり行が 0 行なら実行時エラーとして throwerror があっても行が返っていれば「切り詰め」として warn ログを出して行を使います。切り詰め自体は前節の offset 設計が回収してくれるので処理は続行できますが、warn が出続けるならページサイズか列数を見直すサインです (運用の章で触れます)。

includeNulls: true を明示する

{
  "queries": [{ "query": "EVALUATE TOPNSKIP(100000, 0, '受注明細')" }],
  "serializerSettings": { "includeNulls": true }
}

includeNulls の既定値は false で、このとき null のセルはキーごと応答から消えます。あるページでは全行 null だった列が、次のページでは値を持って現れる — 図の CASE 3 のとおり、ページ間で列集合がぶれるということです。CSV 化するときにヘッダを 1 ページ目で確定できなくなり、列ズレの原因になります。リクエスト body で必ず true を明示します。

リトライは一時的な失敗だけ、1 回だけ

失敗の種類によって、再送に意味があるかは明確に分かれます。

ステータス 扱い
429 / 5xx / ネットワークエラー / タイムアウト 1 回だけ再送
400 (DAX 構文エラー等) / 401 / 403 / 404 即 throw

400 系は再送しても同じ結果が返るだけで、ユーザーあたり 120 リクエスト/分の枠を無駄に消費します。また再送回数を 1 回に留めているのは、この処理の先がストリーミング応答だからです。応答を返し始めた後のリトライは途中まで送ったデータの掃除ができず、粘るほど応答時間が延びるだけ、というのが実環境での結論です。

躓きポイント

タイムアウトは body を読み切るまで張り続ける

executeQueries の応答は最大 15MB あります。fetch にタイムアウトを付けるとき、「ヘッダが到着したらタイマー解除」という素朴な実装だと、本文の読み出しが無防備になります。接続が本文の途中でストールすると res.json() が永久に pending し、インフラ側の request timeout までソケットを掴み続けます。

// AbortSignal は body の読み出し完了まで張り続ける
async function fetchWithTimeout(url, init, timeoutMs, consume) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);
  try {
    const res = await fetch(url, { ...init, signal: controller.signal });
    return await consume(res); // body の読み出しも同じタイマーの内側
  } finally {
    clearTimeout(timer);
  }
}

ポイントは、呼び出し側から consume コールバック (例: (res) => res.json()) を受け取り、本文の読み出しまで同じタイマーの内側で行うことです。筆者はメタ情報の取得に 15 秒、executeQueries に 60 秒を割り当てています。

順序無保証の残存リスクをどう受容するか

前章の設計で「切り詰め・終端誤認・列ぶれ」は検出できますが、TOPNSKIP の順序無保証だけは技術で塞げません。数百万行を複数リクエストに分割する以上、抽出の途中でセマンティックモデルのリフレッシュが走ったり、容量からモデルが追い出されて再ロードされたりすれば、ページ間で走査順が変わり重複・欠落が起こりえます。

筆者はこれを「抽出中はモデルのリフレッシュを避ける」という運用でカバーすることにし、残存リスクとして明示的に文書化しました。塞げないリスクは、隠すのではなく書き残して受容する — この線引きを設計時に決めておくと、後から「実は欠落しうるのでは」という蒸し返しがなくなります。

200 なのに失敗、400 なのに正常系

この API では HTTP ステータスと成否が一対一に対応しません。200 は「成功」または「切り詰め」であり、400 は多くの場合 DAX の構文エラー、つまりこちらのコードのバグです。監視やログをステータスコードで設計すると実態を見誤るので、「error の有無 + 返った行数」を軸にログを組むのがおすすめです。

運用の勘どころ

スロットリングは目安を計算しておく。 100,000 行/コールでページングする限り、120 リクエスト/分に触れるのは 1 分間に 1,200 万行を取り出す規模です。普通は先に応答サイズや処理時間が問題になります。もし枠に触れるなら、リトライの見直しより先に列数の削減を検討してください。

切り詰め warn を監視に載せる。 前章の「error + 行あり」の warn は、設計上は自動回収されるとはいえ、恒常的に出るならページサイズが列数に対して大きすぎます。列数 N に対して 1,000,000 ÷ N 行を上限の目安にページサイズを下げます。

インフラの request timeout が構造的上限。 同期応答でエクスポートを返す構成では、実行時間の上限は Power BI 側ではなくインフラ側で決まります (筆者の環境の Azure Container Apps は HTTP ingress のリクエストタイムアウトが 240 秒)。ページ数が増えて恒常的に超えるようになったら、同期応答に固執せず「非同期ジョブ + Blob 配信 + 完了通知」へ構成を切り替える判断が必要です。

抽出ウィンドウとリフレッシュスケジュールをずらす。 順序無保証対策の運用面です。スケジュール更新の時刻が決まっているなら、エクスポートの実行帯をその前後から外すだけでリスクは大きく減らせます。

制約事項 (2026 年 8 月時点)

  • 本記事のページング方式は、公式に「Not supported」とされた領域を検出可能性で補う運用回避策です。Microsoft のサポート対象ではありません
  • 応答が JSON のため、2^53 − 1 を超える整数 ID は JavaScript 側で精度が落ちえます。該当するデータを扱うなら Arrow 版 API の検討が必要です
  • Arrow 版 executeDaxQueries は執筆時点ではパブリックプレビューです (公式の告知時点の状況であり、GA 済みかどうかは最新の情報を確認してください)。INFO 関数対応・固定行数上限なし・ストリーミング応答と利点は大きく、本記事の方式からの有力な乗り換え先です
  • サービスプリンシパル (Managed Identity 含む) は、RLS 付き・SSO 有効のセマンティックモデルでは executeQueries を利用できません
  • Azure Analysis Services ホスト / AAS ライブ接続のモデルは非対応です
  • 仕様は更新されていくため、最新の公式ドキュメントを確認してください

まとめ

executeQueries は、HTTP + JSON だけでセマンティックモデルから生データを取り出せる手軽な API ですが、上限超過が HTTP 200 で返り無言で切り詰められるという一点だけは、知らずに使うと欠損データを正常出力する罠になります。守るべき不変条件は 4 つ — 終端判定は 0 行のみ、offset は実取得行数で進める、error は 3 箇所すべて検査する、includeNulls: true を明示する。この 4 つが揃っていれば、欠落は防げないケースでも必ず検出できます。

ここで取り出した行を CSV/ZIP としてストリーミング配信する実装編は、続編「Power BI の生データを CSV/ZIP でストリーミング配信する — Node.js Streams と archiver の地雷処理」として公開しています。

参考リンク

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?