case-kの備忘録

日々の備忘録です。データ分析とか基盤系に興味あります。

Oracle PL/SQL→Databricks マイグレーションで作った LLMコンバージョンツールと dbt 評価基盤の紹介

Databricks Advent Calendar 2025 シリーズ2 25日目の記事です。Oracle を Databricks にマイグレーションする機会があり、LLM を用いた自動コンバージョンツールとコンバージョン結果を評価するための dbtの評価基盤を構築しました。本記事では、コンバージョン評価プロセス全体の流れとその開発・検証の過程で得られた技術的な Tips をご紹介します。LLMを用いたマイグレーション全般に適用できる内容として整理しているため、同じような悩みを持つ方の参考になれば幸いです。

コンバージョン評価プロセス全体の流れ

最初にコンバージョン評価プロセス全体の流れを紹介します。PL/SQL のコンバージョンには LLM ベースのコンバージョンツールを使用しています。また、コンバージョン結果をデータ比較し、評価する仕組みも必要です。評価基盤にはdbtを利用しています。LLMコンバージョンツールやdbt 評価基盤の詳細は次の章で説明し、まずは全体のプロセスの流れを説明します。

データ評価を行うためには、正解データを用意する必要があります。そこで、既存の Oracle 環境において日次で実行されている Procedure バッチ処理完了後のテーブルを、Databricks にロードしています。ロードしたテーブルには日付ごとにサフィックスを付与し、カタログを分けて管理しています(例:prod_oracle_20251225)。日付ごとに断面を分けている理由は、静的なデータ断面を用いることで、複数のデータ評価者がそれぞれ割り当てられた日付断面を使用し、互いに影響を受けることなく、独立した環境でデータ評価を行えるようにするためです。また、この後紹介するProcedureのデータ比較評価に必要な環境を作るためにも利用されます。

ロードされたOracleのデータを用いて、Procedureのデータ比較評価に必要な環境(以降、評価環境と呼びます)を構築します。

評価環境で使用するデータは、次の 2 種類です。
1 つ目は、日次で連携され、Procedure のインプットとなるテーブルです(以降「インプットテーブル」と呼びます)。
2 つ目は、インプットテーブル以外で、Procedure の実行によって更新されるテーブルです。

評価者はそれぞれデータ比較する対象日をdbt の設定ファイル内で指定して評価を行います。インプットテーブルには、指定された日付断面の Oracle テーブルを使用し、それ以外のテーブルについては、指定日付の前日断面の Oracle テーブルを利用します。このように構成することで、Procedure 実行後の評価環境は、テーブルが更新され、Oracle におけるバッチ処理完了後の状態と一致します。

また、評価環境の構築時には、データレベルの前処理が必要です。Oracle と Databricks ではデータ型の挙動に差異があるためです(例:CHAR 型は Oracle ではCHARサイズに応じてスペース埋めされますが、Databricks ではされません)。こうした差異は、 Procedure のデータ比較評価結果をもとに、前処理として吸収しています。次にLLMコンバージョンツールを使い、Oracle PL/SQLをDatabricks Procedureにコンバージョンします。Databricks Procedureについては以下に詳細を書きました。

case-k.hatenablog.com

LLMコンバージョンツールでは正規表現等の「ルール処理」と「LLM処理」を組み合わせて自動コンバージョンしています。Databricks ProcedureはSQLウェアハウスで実行できるので、変換後にAPIを使い、「CREATE PROCEDURE」でUnity Catalogに登録します。Syntaxレベルのエラーがあった場合、「CREATE PROCEDURE」実行時に検知することができます。PySparkでのジョブ起動等ないため、改善サイクルを高速に回すことができました。Syntaxエラーとなった場合、エラーメッセージとコンバージョン結果をプロンプトに組み込み、リトライします。数回リトライしても改善しなかった場合、「人間」が確認し、LLMコンバージョンツールに修正等フィードバックします。Syntaxレベルのエラーが改善されるまで、繰り返します。

次にdbtを用いて、Procedureの実行とデータ比較評価を行います。dbt Pythonモデルを用いてProcedureを実行すると、先ほど紹介した評価環境のテーブルが更新されます。更新された結果はOracleからロードした当日断面と同じ結果になっているため、データ比較することで、差分の確認ができます。

データ比較の差分結果を「人間」が確認し、どのタスクにフィードバックするか判断し修正することで、改善サイクルを回します。データ評価のフィードバック先は多岐に渡ります。依存関係等のメタ情報の不備の場合もあれば、テーブル定義、データの前処理、LLMコンバージョンツール、コンバージョン結果、UDF等考えられます。データの差分結果を考慮して、適切なタスクにフィードバックして、改善します。

Procedure の評価が完了した後は、日次で連携されてくるProcedureのインプットテーブルの評価を行います。インプットテーブルはオブジェクトストレージ(S3等)経由で連携を想定します。連携時にはまだProcedureのフィードバックを反映したインプットテーブルになっていないため、前処理を行い、評価環境のインプットテーブルと一致するようにデータ比較します。

全体の評価フローは、まず Procedure 単体テストから始めます。次に Procedure 同士の依存関係を考慮した結合テストを行い、その後、Procedureのテスト結果が反映された評価環境を正解データとし、インプットテーブルの単体テストをします。最後に、インプットテーブルと Procedure を組み合わせた結合テストを行います。一連の評価が完了した後は、日次バッチとして実行し、継続的に監視します。並行稼働期間中は、データの差分や不整合を継続的に検知できるようになります。

以上が、コンバージョン評価プロセス全体の流れです。次章からは具体的なLLMコンバージョンツールとdbt評価基盤の技術的なTipsをご紹介していきます。

LLMコンバージョンツール

LLMコンバージョンツールで行っていることの詳細をご紹介します。


ルール処理(前処理・後処理)

LLM は非決定的であり、コンテキストウィンドウや出力トークンにも制限があります。そのため、正規表現などで対応可能な部分についてはルールベース処理を採用し、正規表現では対応が難しい箇所のみ LLM を利用しています。ルールベースで対応可能な部分はルール処理に任せることで、変換精度と安定性を高めています。


コメント削除等によるコンテキスト削減

前処理では、 PL/SQL に含まれるコメントや不要な改行を削除し、LLM に渡す前のコンテキスト量を削減しています。これにより、コンテキスト量をおおよそ 1/3 程度削減することができました。空白等も当初は削除していましたが、Procedureのデータ比較を通して、削除してはいけないことがわかったので、対象から外しています「normalize_whitespace: false」。

input_preprocessing:
  remove_comments: true # Remove all SQL comments (-- and /* */) from input
  remove_empty_lines: true # Remove empty lines from input
  normalize_whitespace: false # Normalize multiple spaces/tabs to single space
  compress_sql_formatting: true # Remove line breaks within SQL statements for compact formatting
正規表現での変換

正規表現で変換可能な箇所については、すべて正規表現による「ルール処理」で対応しました。具体的には、日付フォーマットの変換や日本語変数をバッククオートで囲む処理、Oracle 互換の関数変換などを正規表現で変換しました。LLM は変換結果が不安定になるケースもあるため、正規表現で対応できる部分については、ルールベース処理の方がより正確かつ安定した変換が可能です。

  • (例)日本語を含む変数に対するバッククオートの付与

日本語を含む変数名に対するバッククオートの付与は、LLM では特に変換漏れが発生しやすいポイントでした。英語と日本語が混在するケースでは、LLM がカラムを見落とすことが多く、安定した変換が困難でした。

INSERT INTO
  `TEMP_利用者`(
     ID,
  `名前`
) 
SELECT 
   USER.ID
   USER.`ユーザー` 
FROM USER
  • (例)日付フォーマット変換

PL/SQL と Databricks では日付フォーマットの指定方法に差異があるため、これらは正規表現によるルール処理で変換しています。

YYYYMMDD    → yyyyMMdd
yyyy/mm/dd  → yyyy/MM/dd
YYYY/MM/DD  → yyyy/MM/dd
YYYY/mm/dd  → yyyy/MM/dd
  • (例)Oracle 互換関数の変換

Oracle 互換の関数については、Databricks 側に互換 UDF を用意して利用しています。関数の変換等はLLMよりもルールベースの方が正確かつ、LLMで消費するコンテキスト量も減らすことができます。

function_mappings:
  TO_DATE: "oracle_compatible.TO_DATE"  # TO_DATE(str, fmt) → oracle_compatible.TO_DATE(str, fmt)
  TO_CHAR: "oracle_compatible.TO_CHAR"  # TO_CHAR(value, fmt) → oracle_compatible.TO_CHAR(value, fmt)
  ADD_MONTHS: "add_months"  # ADD_MONTHS(date, n) → add_months(date, n)
  NVL: "coalesce"  # NVL(expr1, expr2) → coalesce(expr1, expr2)
  TRIM: "oracle_compatible.TRIM"  # TRIM(col) → oracle_compatible.TRIM(col)
  RTRIM: "oracle_compatible.RTRIM"  # RTRIM(col) → oracle_compatible.RTRIM(col)
  LTRIM: "oracle_compatible.LTRIM"  # LTRIM(col) → oracle_compatible.LTRIM(col)
  SUBSTRB: "SUBSTR"
  SYSTIMESTAMP: "current_timestamp()"  # SYSTIMESTAMP → current_timestamp()
  SYSDATE: "current_timestamp()"  # SYSDATE → current_timestamp()
  TO_NUMBER: "oracle_compatible.TO_NUMBER"  # TO_NUMBER(col) → oracle_compatible.TO_NUMBER(col)
  TRUNC: "FLOOR"  # TRUNC(number, scale) → FLOOR(number, scale)
  current_date: "current_timestamp()"
   ....

LLM処理

ルール処理だけでは対応が難しい部分は「LLM処理」で対応しています。行数の多いPL/SQLをコンバージョンするための工夫やLLMの不安定さを取り除くための方法を紹介します。


数千行以上のPL/SQL をコンバージョンする方法:会話履歴・出力の分割と結合・リクエストパラメータ制御

数千行を超えるような行数の多い PL/SQL は、前処理によってコンテキスト量を削減しても、モデルの出力トークン制限を回避することは難しいです。
例えば Claude 3.7 Sonnet では、コンテキストウィンドウ(200,000トークン)および出力トークン(64,000トークン)に上限があり、そのままリクエストすると出力トークン制限に達してしまいます。また、モデルの許容するコンテキスト上限ギリギリでリクエストした場合、出力の一部が途中で欠落することも確認できました。
そこで、リクエスト時に余裕を持った出力トークン 「max_output_tokens 」を設定し、出力トークンの上限に達した場合は、途中までの変換結果を会話履歴として保持し、続きから再生成する仕組みを実装しました。この方法により、ベンダーが提供するモデルの出力トークン制限を回避しています。

具体的には、以下の流れで処理を行っています。

conversion_settings:
  default_temperature: 0.0
  default_max_tokens: 60000 # Set max_output_tokens with a sufficient margin.
  default_stream: true # Enable streaming output by default

出力トークンの上限に達した場合は、生成済みの結果を会話履歴として保存します。finish_reason が「length」の場合、次の LLM リクエストではその履歴をプロンプトとあわせて渡し、前回の続きからコードを再生成します。この処理を繰り返し、分割生成された結果を CONCAT して統合することで、行数の多い Procedure の変換を行っています。

# Continue if truncated due to length limit
while final_finish_reason == 'length' and continuation_count < max_continuations:
    continuation_count += 1
    print(f"{context_prefix}Response truncated, requesting continuation {continuation_count}/{max_continuations}...")
    
    # Add continuation request to conversation history
    current_messages.append({"role": "user", "content": continuation_prompt or 
                            "Please continue from where you left off. Provide only the continuation without repeating what's already provided."})
    
    try:
        if stream:
            # Use streaming version for continuation
            continuation_content, finish_reason, cont_token_usage = self._single_stream_request_with_metadata(
                current_messages, temperature, max_tokens, context_prefix
            )
    except Exception as e:
          ......

    full_text += continuation_content
    current_messages.append({"role": "assistant", "content": continuation_content})
    final_finish_reason = finish_reason

PL/SQL のコンバージョンでは正確性と一貫性が重要となるため、「temperature」は低めに設定し、出力の揺らぎを抑えています。また、行数の多いファイルでは タイムアウトが発生するするため、「stream」オプションを利用してストリーミングで結果を受け取ることで回避できます。さらに、出力トークンは意図的に小さく設定し、上限に達した場合は会話履歴を用いた分割生成を行うことで、出力トークン制限に対応しています。出力トークンを意図的に小さく設定しないと、LLM側で勝手に出力結果を削ったり、サマライズしたりしてしまうので、注意が必要です。
以下のサンプルはOpenAI Client を使用し、ストリーム処理で、Databricksのモデルサービングを利用する例です。
Use foundation models | Databricks on AWS

def chat_completion_stream(self, messages: List[Dict[str, str]], temperature: Optional[float] = None, 
                          max_tokens: Optional[int] = None) -> Iterator[Dict[str, Any]]:
    """Send streaming chat completion request to Databricks Model using OpenAI SDK"""

    params = {
        'model': self.model_id,
        'messages': messages,
        'temperature': temperature
    }
    
    if max_tokens is not None:
        params['max_tokens'] = max_tokens
    
    # Use OpenAI SDK for streaming (following working sample.py approach)
    with self.openai_client.chat.completions.stream(**params) as stream:
        for event in stream:
            if event.type == "chunk":
                print(f'raw chunk finish_reason: {event.chunk.choices[0].finish_reason}')
                finish_reason = event.chunk.choices[0].finish_reason
            if event.type == "content.delta":
                # event.delta is the text fragment (plain string)
                chunk_text = event.delta
                print("content.delta")
                print(event)
                
                # Create OpenAI-compatible chunk format
                chunk = {
                    'choices': [{
                        'delta': {
                            'content': chunk_text
                        },
                        'index': 0
                    }]
                }
                yield chunk
                
            elif event.type == "error":
                # Return error in expected format
                yield {"error": event.error}
                break
            elif event.type == "content.done":
                # Debug: check event attributes
                print(f"🔍 content.done event: {event}")
                
                # Send final chunk with actual finish_reason
                final_chunk = {
                    'choices': [{
                        'delta': {},
                        'index': 0,
                        'finish_reason': finish_reason
                    }]
                }
                yield final_chunk
                break

このようにして、LLM モデルの出力トークン制限を回避しつつ、コンテキストウィンドウの上限近くまで対応できるようになります。
ただし、この処理をしてもコンテキストウィンドウの制限を回避することはできないので、数万行レベルのPL/SQLは事前に分割しておく必要があります。また、5,000 行を超える PL/SQLでは変換精度が低下する傾向があるため(コンテキストロット問題:入力トークン量(会話履歴)増加に伴う能力低下現象)、一定の行数を超える場合は、前処理として PL/SQL自体を事前に分割した方が良さそうです。会話履歴を活用した分割変換は、精度を維持したまま制限を回避できる有効な手法でした。一方で、履歴情報の渡し方ではコンテキスト圧縮なども試しましたが、十分な効果は得られませんでした。PL/SQL のコメントはできれば残したいと思いますが、精度とトレードオフとなってくるので、コンテキストウィンドウの制限がある現時点ではバランスを見て判断した方がいいと思います。

プロンプトの継続的改善とバージョン管理

PL/SQL を Databricks Procedure に変換するにあたり、LLM が十分な知識を持っていなかったため、公式ドキュメントやベンダー側のベストプラクティスをもとにベースプロンプトを作成しました。ただし、ドキュメントに記載のない仕様も多くあるため、データ評価結果をプロンプトにフィードバックし、継続的に改善しました。当初は関数変換も LLM に任せていましたが、不安定さや変換漏れが多く、また評価結果を反映しすぎるとコンテキスト肥大によるデグレも発生しました。そのため最終的には、「ルール処理」で対応できない部分のみに 「LLM 処理」を限定し、コンテキストを抑えることで精度と安定性を両立しています。また、LLMが十分な知識を持っていないため、「LLM処理」もしくは「ルール処理」どちらにも変換方法を明示していないものは不安定な出力となりました。コンバージョンでは変換結果の一貫性が重要となるため、基本的にはどちらかには変換方法を明示しています。
また、プロンプトのバージョン管理は GitHub のみで行っています。改善サイクルを高速に回すことを重視していたため、ローカル環境から実行でき、VS Code エディタ内で完結する形で GitHub 管理としました。デグレが発生した場合も、プロンプトファイルのコミットを戻すだけで対応できます。


docs.databricks.com

Syntaxエラー自動リトライによる出力の安定化

PL/SQL コンバージョンの単体テストでは、Syntax・Runtime・データレベルの 3 段階で評価しています。このうち Syntax レベルの評価は、LLM コンバージョンツール内で、自動修正しています。Runtime・データレベルはデータ起因や他の要因も考えられるため、LLM コンバージョンツール内では対応していないです。


「コンバージョン評価プロセス全体の流れ」で紹介した通り、コンバージョン結果をAPI 経由で SQL ウェアハウスに対して 「CREATE PROCEDURE」 を実行し、Syntax エラーがあれば即時検知されます。LLM の不安定さを考慮し、プロンプトにコンバージョン結果とSyntax エラーメッセージを加え、最大 3 回までリトライし、それでも解消しないものは「人間」が確認してツールにフィードバックしています。ツール内の「LLM処理」を改善するか、「ルール処理」を改善するかはエラー内容を見て判断しました。エラー内容やSyntaxエラーとなったコンバージョン結果はファイルに出力し、トレーシング可能な形で管理し、フィードバックの参考にしています。先ほどもお伝えしたとおり、改善サイクルを高速に回すため、ローカル環境で完結できることを重視していました。そのため、コンバージョン結果やSyntaxエラー内容をファイルとして出力し、トレーシングできる運用を取りました。

以下はSyntaxエラーリトライ時のサンプルとなります。プロンプトにコンバージョン結果とエラーメッセージを加えて、リトライしてるのが確認できます。

for attempt in range(1, max_retries + 1):
    print(f"{error_type} fix attempt {attempt}/{max_retries}")
    
    try:
        # Create fix prompt based on error type
        if error_type == "syntax_error":
            fix_prompt = f"""The following Databricks SQL has a syntax error. Please fix it.
            Error message:
            {current_error}

            SQL:
            {current_sql}

            Please return only the corrected SQL (no comments or explanations needed)."""

評価・改善はすべてローカル環境で高速に回し、全ProcedureのSyntaxレベルの改善は1週間程度で対応できました。Databricks Procedureがサポートされ、直接SQLウェアハウス実行できるようになったことも改善サイクルの高速化につながりました。

dbt 評価基盤

dbt 評価基盤で行っていることの詳細をご紹介します。


正解データの準備

「コンバージョン評価プロセス全体の流れ」で紹介した通り、日次で Oracle のテーブルを Databricks にロードしています。ロードには dbt Pythonモデルを利用しています。dbt Pythonモデルは Python で ETL ロジックを記述でき、Databricks の Notebook Job として実行可能です。SQL では難しい処理にも対応でき、SQLモデルとの依存関係も制御できます。dbtのエコシステムを利用できるので便利です。


Databricks configurations | dbt Developer Hub
Python models | dbt Developer Hub

以下のサンプルはdbt Pythonモデル内で、JDBCドライバを使い、Oracleのテーブルをロードするサンプルです。テーブル数が多い場合、テーブルごとにモデルを作成するとモデル数が膨大になってしまうので、マルチスレッド処理等で対応した方が良いと思います。Pythonが使えるので、柔軟な処理が可能です。

(例) Oracleに永続化されているテーブルをDatabricksへロード

import boto3

def model(dbt, session):
    dbt.config(
        materialized='table',
        tags=['sample'],
    )

    host = dbt.config.get('dbx_host')
    ssm = boto3.client('ssm', region_name=dbt.config.get('region'))
    user = ssm.get_parameter(Name=dbt.config.get('jdbc_user_path'), WithDecryption=True)['Parameter']['Value']
    pwd = ssm.get_parameter(Name=dbt.config.get('jdbc_password_path'), WithDecryption=True)['Parameter']['Value']
    jdbc_url = ssm.get_parameter(Name=dbt.config.get('jdbc_url_path'), WithDecryption=True)['Parameter']['Value']
    fetchsize = dbt.config.get('fetchsize')
    src = 'sample'
    print(f'table: {src}')
    data_q = f"(SELECT * FROM {src})"
    try:
        df = session.read.format('jdbc') \
            .option('url', jdbc_url) \
            .option('dbtable', data_q) \
            .option('user', user) \
            .option('password', pwd) \
            .option('driver', 'oracle.jdbc.OracleDriver') \
            .option('fetchsize', fetchsize) \
            .load()
    except Exception as e:
        print(f"Error processing {src}: {e}")
        raise e

    return df

また、PL/SQLにも大きく2種類あり、バッチ処理として実行されているものと、レポーティングツールなどからテーブル関数のように利用され、最終的なSELECT 結果を取得するものがあります。前者は Oracle 上にテーブルとして永続化されていますが、後者は Oracle 上に結果が永続化されないため、そのままでは正解データを用意できません。そこで後者については、Databricks から PL/SQL を実行し、その実行結果を Databricks 上に永続化することで、正解データを準備できます。正解データの準備は、いずれの場合も dbt Python モデルを利用しています。

(例) PL/SQLのSELECT結果をDatabricksにロード

dbt Python モデルでは、jpype1==1.5.2 を利用して PL/SQL を実行し、実行結果を取得しました。
(当初は 「python-oracledb」 の利用を検討しましたが、一部のPL/SQLでライブラリのバグを踏んでしまい、タイムアウトする事象が発生しました。jpype1==1.5.3 は Issue があったため、安定して動作した 1.5.2 にバージョンを固定しています。PL/SQLが多いとバグを踏む機会も多くありました)

def execute_procedure(conn, spec: Dict[str, Any], fetch_full: bool = True, preview_rows: int = 10) -> Dict[str, Any]:
    proc_schema = spec.get('schema')
    proc_name = spec['name']
    params_meta: List[Dict[str, Any]] = spec.get('parameters_full', [])
    call_sql = '{' + 'call ' + proc_schema + '.' + proc_name + '(' + ','.join(['?'] * len(params_meta)) + ')}'
    print(f"Executing: {call_sql}")

    # Force session date format to avoid ORA-01861 for procedures using TO_DATE without format mask
    try:
        fmt_stmt = conn.createStatement()
        fmt_stmt.execute("ALTER SESSION SET NLS_DATE_FORMAT='YYYY/MM/DD'")
        fmt_stmt.execute("ALTER SESSION SET NLS_TIMESTAMP_FORMAT='YYYY/MM/DD HH24:MI:SS'")
        fmt_stmt.execute("ALTER SESSION SET TIME_ZONE='Asia/Tokyo'")
        fmt_stmt.close()
    except Exception as e:
        print(f"[WARN] ALTER SESSION failed: {e}")

    stmt = conn.prepareCall(call_sql)

Client Challenge

評価環境の準備

「コンバージョン評価プロセス全体の流れ」で紹介した通り、Procedureの「評価環境」を構築します。先ほどお伝えした通り、評価環境を構築するためにOracleの当日断面(インプットテーブル)と前日断面を利用します。構築時にはProcedureのデータ比較評価で得たフィードバックを前処理に反映しています。

例えば CHAR 型の場合、Oracle では空文字が入った際に、定義された CHAR サイズ(例:CHAR(8))に応じてスペース埋めされた値が格納されます。一方、Databricks では CHAR サイズによるスペース埋めは行われません。そのため、テーブルを作成する際に、デフォルト値としてスペース埋めされるような対応を入れています。同様に、CHAR 型以外でも Oracle の DATE 型は「時刻」も持っているため、DatabricksではDATE型ではなく、TIMESTAMP型として扱う必要があります。このようにテーブル定義を作成する際にOracleとDatabricksのデータレベルの差分を前処理で吸収する必要があります。これらは最初からわかっていたわけではなく、Procedure のデータ比較評価結果を進める中で、継続的に改善していきました。

以下はDatabricks上でテーブル定義の情報を取得するクエリです。JDBCドライバで Oracle から Databricks にロードしたテーブルのスキーマ情報を管理する「information_schema」と、Oracleスキーマ情報を管理している 「dba_tab_columns 」を利用して、今回のマイグレーションで利用している テーブル定義を作成しています。CHAR型かつデフォルト値がない場合、デフォルト値としてCHARサイズに応じたスペース埋めをデフォルト値として設定しています。

with information_schema as (
SELECT
    table_schema,
    table_name,
    column_name,
    data_type,
    full_data_type,
    ordinal_position
FROM
    {copy_src_database_day_before}.information_schema.columns
WHERE
    lower(table_schema) IN ('schema_a', 'schema_b')
),
dba_tab_columns as (
select distinct
    OWNER,
    table_name,
    column_name,
    case
        when trim(DATA_DEFAULT) in ('NULL','null') then Null
        when
        trim(DATA_DEFAULT) in ('SYSDATE', 'sysdate', 'systimestamp', 'current_timestamp', 'SYSTIMESTAMP')
        then
        'CURRENT_TIMESTAMP()'
        when trim(DATA_DEFAULT) == "'1980/01/01'" then 'to_timestamp("1980-01-01","yyyy-MM-dd")'
        else trim(DATA_DEFAULT)
    end as DATA_DEFAULT,
    NULLABLE
from
    {database}.{schema}.dba_tab_columns
where
    lower(OWNER) IN ('schema_a', 'schema_b')
)
select
a.table_schema,
a.table_name,
a.column_name,
a.data_type,
a.full_data_type,
CASE 
    WHEN lower(a.data_type) = 'char' 
          AND b.NULLABLE = 'N' 
          AND trim(b.DATA_DEFAULT) = "' '"
    THEN CONCAT("'", REPEAT(' ', CAST(REGEXP_EXTRACT(a.full_data_type, '(?i)CHAR\\\\((\\\\d+)\\\\)', 1) AS INT)), "'")
    ELSE b.DATA_DEFAULT
END as DATA_DEFAULT,
b.NULLABLE
from
information_schema as a
    left outer join dba_tab_columns as b
    on lower(a.table_schema) = lower(b.OWNER)
    and lower(a.table_name) = lower(b.table_name)
    and lower(a.column_name) = lower(b.column_name)
ORDER BY
table_schema,
table_name,
ordinal_position asc

作成したテーブル定義に対して、Procedure のデータ評価結果を考慮した前処理を行ったうえで、データを INSERT しています。CHAR 型のデータについては、値がオールスペースの場合はそのまま扱い、それ以外の場合は RTRIM を適用しています。これは、データ比較を進める中で、WHERE 条件や GROUP BY においてスペースの有無が差分として検知されるケースがあったためです。
また、文字列系はかなりややこしく、CHARやSTRING、VARCHAR型で挙動が異なります。当初は評価環境を構築する際に、ORACLEからロード済みのテーブルをSELECTする際に、適切なデータ型にCASTし、CREATE TABLEして構築していました。しかし、文字列系はCASTしてもCASTしたデータ型になりませんでした(CHAR(8)でCASTしてもSTRINGになってしまったり)。そのため、まず空のテーブルを作ってからINSERTするようにしました。これらもProcedure のデータ比較評価結果を進める中で、継続的に改善していきました。CHAR ではなく STRING になってしまうと、Procedure 内の WHERE 条件などでスペースを指定している場合、スペースの扱いが異なり、条件にマッチせず、適切な処理結果にならなくなります。

if 'char' in data_type:
    # If column contains only whitespace characters, keep original value; otherwise apply rtrim and cast to full_data_type
    column_selects.append(f"CAST(CASE WHEN `{column_name}` RLIKE '^\\\\s+$' THEN `{column_name}` ELSE rtrim(`{column_name}`) END AS {full_data_type}) as `{column_name}`")
elif 'timestamp' in data_type:
    column_selects.append(f"CAST(CASE WHEN `{column_name}` IS NULL THEN NULL ELSE `{column_name}` - INTERVAL 9 HOURS END AS {full_data_type}) as `{column_name}`")
else:
    column_selects.append(f"`{column_name}`")

「コンバージョン評価プロセス全体の流れ」で紹介した通り、インプットテーブルは当日断面、それ以外は前日断面から取得しています。

# copy_src_database:prod_oracle_20251225
# copy_src_database_day_before:prod_oracle_20251224

if is_source_table:
    insert_query = f"INSERT INTO {target_database}.{copy_src} SELECT {columns_str} FROM {copy_src_database}.{copy_src};"
else:
    insert_query = f"INSERT INTO {target_database}.{copy_src} SELECT {columns_str} FROM {copy_src_database_day_before}.{copy_src};"

評価環境は評価者ごとにローカル環境から割り当てられた日付断面を使い、構築しています。dbtの設定ファイル(dbt_project.yaml)に変数として日付情報をセットすることで、対象日の評価環境を構築しています。

Databricks Procedureの実行

「コンバージョン評価プロセス全体の流れ」で紹介した通り、Databricks Procedureはdbt Python モデルから実行しています。Databricks Procedureは新しい機能なので、不安はあったのですか、もし何か問題があればPython で処理できるため、Databricks Procedureの導入を決める上で、安心材料となりました。
いきなり全 Procedure をテストすると原因調査が困難になるため、dbtのタグ機能を活用し、Procedure 単位で実行を制御しています。「dbt tag」を利用することで、実行時に 特定のタグだけ実行・除外 することができます。この機能を利用することで、同じdbtモデルファイルを利用し、Procedureの単体テスト、依存関係を考慮したProcedureの結合テストが可能となります。

具体的には以下のような「dbt tag」を使っています。Procedrueの単体テスト結合テストができるように「procedure名」、「procedureの属する親」、「procedureの属すスキーマ名」、「バッチ処理全体」単位と複数のタグを1つモデルに紐付け、依存関係も考慮した柔軟な実行単位の制御が可能です。

def model(dbt, session):
    dbt.config(
        tags=['<procedure_ parent_name>',  '<procedure_name>', 'batch', '<procedure_schema>']
    )

dbtのtagについて細かな挙動を確認してみた #dbt | DevelopersIO

また、「dbt.ref」を活用することで依存関係や同時実行数を考慮した並列実行が可能です。「dbt.ref」はdbtのSQLモデルとPythonモデル間でも定義することができます。マイグレーションをする場合、既存のPL/SQLの依存関係をそのまま持ってきたいことがあると思います。マイグレーションで依存関係を変更してしまうと、データ比較等で差分が出た場合、原因調査がむずかしくなるためです。Procedure間の依存関係は事前にメタ情報として定義し、メタ情報を利用して、dbt Pythonモデルファイルは自動生成しています。

def model(dbt, session):
    dbt.config(
        tags=['<procedure_ parent_name>',  'procedure_name', 'batch', '<procedure_schema>']
    ) 
    
    # Load reference models
    refs = [dbt.ref("procedure_a")]

About ref function | dbt Developer Hub

また、特定のカレンダー条件に応じて実行可否を制御したかったり、諸々の制約もあり、クラスターではなくSQL ウェアハウスのリソースを活用したいなどの特別な要件がある場合があります。そのような特殊な要件があっても、dbt Pythonモデルを使うことで柔軟に対応することができます。

通常の dbt は「1 モデル=1 テーブル」を前提としていますが、Procedureの場合、「1モデル= 1Procedure」となります。dbtの仕様上テーブル化しないといけないので(依存関係の制御でも利用)、Procedure 実行後に処理完了を示すタイムスタンプを返す運用をとっています。Procedureがどこまで完了してるか調べやすくしています。以下のような形をとっています。

from databricks import sql # Import databricks-sql-connector to use SQL Warehouse resources instead of a cluster.
from utils import calendar
import pandas as pd
....

def model(dbt, session):
 #  tag definition
    dbt.config(
        tags=['<procedure_ parent_name>',  'procedure_name', 'batch', '<procedure_schema>']
    )

 # model dependencies
 refs = [dbt.ref("procedure_a")]
  
 # Control execution eligibility based on specific calendar conditions.
    if calendar.should_execute_today(dbt.config.get("calendar_table"), "bizday", session, dbt.config.get("calendar_date")):
        print('sp execute')
        try:
            #  Procedure execution
            procedure_b(spark=session, params=params)
           
   # Return the procedure completion timestamp.
   return session.sql("SELECT current_timestamp() as createdtime, 'success' as status")
        except Exception as e:
            error_msg = f"Model procedure_b execution failed: {str(e)}"
            print(f"ERROR: {error_msg}")
            raise e
    
 # Return the procedure completion timestamp.
 return session.sql("SELECT current_timestamp() as createdtime, 'skip' as status")

データ比較評価

「コンバージョン評価プロセス全体の流れ」で紹介した通り、Procedureの実行結果をOracleの正解データと比較して評価します。また、Procedureの評価完了後はProcedureのフィードバックを反映した「評価環境」を正解データとし、インプットテーブルの評価も必要です。

Procedure単位での単体テストを行うため、事前にメタ情報としてProcedure単位で、書き込み処理をしてるテーブル一覧を整理しています。Procedure の実行によって更新されるテーブルを対象にデータ比較評価することで、Procedure 単体でのテストが可能になります。

write_tables:
- table_name: table_a
  procedure: procedure_a
  schema: schema_a
- table_name: table_b
  procedure: procedure_a
  schema: schema_a
- table_name: table_a
  procedure: procedure_b
  schema: schema_b

Databricks Procedure の実行結果を評価するために、dbt Audit Helper を活用しています。カラムレベルおよびレコードレベルでのデータ比較が可能で、一致率も算出できるため、これらを指標として差分検知を行っています。dbtのエコシステムを使えるので、自前で作る必要もなく便利でした。
以下は行レベルの比較処理を行うサンプルとなります。「summarize」オプションを「TRUE」にするとデータ比較の一致率を確認することができます。「FALSE」にすると具体的にどの行で差分が発生しているかわかります。カラムレベルの比較等も可能です。データ比較結果はdbtモデルとしてテーブルに書き込まれます。
github.com

「dbt tag」は Procedure の実行単位に合わせて設計しています。これにより、Procedure 実行後に更新されたテーブルに対してデータ比較テストを実行できるようになっています。これらのファイルについても、先ほど紹介したメタ情報を利用して自動生成しています。

{{ config(
  materialized='table',
  tags=['<procedure_schema>', '<procedure_name>_test', '<procedure_parent_name>_test',  '<table_name>_compare_queries_summarize', 'test_compare_queries_summarize']
) }}

{% set old_query %}
  select
    col_1,
    col_2
  from {{ var('catalog_table_compare_old') }}.<schema_name>.<table_name>
{% endset %}

{% set new_query %}
  select
    col_1,
    col_2
  from {{ var('catalog_table_compare_new') }}.<schema_name>.<table_name>
{% endset %}

{{ audit_helper.compare_queries(
    a_query = old_query,
    b_query = new_query,
    summarize = true
) }}

以下のようにして、「dbt tag」で単体テストをしたいProcedureのデータ比較が可能です。データの比較結果はテーブルとして書き込まれます。

pipenv run dbt run --target=prod --select tag:<procedure_name>_test --threads 5

dbt Audit Helperのデータ比較結果に対して、dbt test を実行し、データ比較結果(一致率)に差分があった場合は検知できるようにしています。検知された結果は「人間」が確認し、LLM コンバージョンツールや評価環境構築処理、メタ情報などにフィードバックすることで、データレベルでの改善サイクルを回しています。dbt testファイルは以下のようにしています。

{{ config(
 tags=['batch', '<schema>', 'test_<table_name>', '<procedure_name>_test', '<procedure_parent_name>_test']
) }}
 
select
 1
from
 {{ var('catalog_compare_new') }}.{{ var('schema_dbt_model') }}.<table_name>_<schema_name>_compare_queries_summarize
where
 not (in_a = 'true' and in_b = 'true' and percent_of_total = 100)

以下のコマンドでテストできます。単体テスト結合テスト等テストフェーズに合わせた「dbt tag」を指定し、柔軟にテストすることができます。

pipenv run dbt test --target=prod --select tag:<procedure_name>_test --threads 5

データ比較評価結果のフィードバック

「コンバージョン評価プロセス全体の流れ」で紹介した通り、データ比較評価結果のフィードバックはLLMのコンバージョンツールだけではなく、データの前処理、メタ情報、Oracle互換のUDF等多岐に渡ります。差分を確認した場合、どこに、どのように反映していくのが適切か人間が判断していく必要があります。運用としては、評価者が データ比較後に原因を調べ、Databricks Procedure を修正した Pull Request を作成し、レビュー担当者がその差分を確認したうえで、適切なフィードバック先を評価者と議論し、判断する運用としました。フィードバックに必要なコンテキスト情報は、基本的にはPull Request から取得する運用としています。


大変だったこと

プロンプトチューニングに伴うLLMのデグレ対応

データ評価のフィードバックをプロンプトへ反映していく過程で、コンテキストが肥大化し、精度がデグレする現象を確認しました。デグレが発生した場合は、GitHub 上でバージョンを戻すなどの対応を行いました。
また、以下のような運用と対策を取りました。

  • ルール処理で対応可能なものは、極力ルールベースで処理する(コンテキストの肥大化を防ぐ)
  • 変換結果は Pull Request でレビューする運用とする(GitHubの差分で明らかなデグレが発生していないか確認する)
  • 評価済みの Procedure は再コンバート対象から除外する
  • 変換処理を「LLM 処理」か「ルール処理」のいずれかに明示する(曖昧だとLLMの知識に頼ることになり、変換結果が不安定になる)
  • 行数が多く、ビジネスロジックを含まないバッチ系 Procedure から優先的にコンバージョンを進める(ビジネスロジックは行数が少ないものの複雑な処理が増え、プロンプトのコンテキストが増えるため)

ルールベース処理と異なり、LLM は非決定的であるため不安定な側面があり、プロンプトのチューニングは手探りで進める必要がありました。そのため、プロンプト調整やデグレ対策には特に難しさを感じました。

データ比較評価

Syntaxレベルのテストは全Procedure1週間程度でクリアできましたが、データ比較はかなり大変でした。本記事でも一部ご紹介しましたが、データ比較で差分が出た場合、原因は多岐に渡ります。また、Procedureをテストするためのデータ準備等も自動化しているとはいえ、待ち時間はかかります。特に、複数の Procedure が同一のテーブルを更新している場合、原因調査はさらに難しくなります。正解データとして参照できるのは Oracle のバッチ完了後の断面のみであり、複数の Procedure によって更新された「最終更新結果」としかデータ比較ができないためです。その結果、どの Procedure の処理で問題が発生したのかを特定するのは困難になります。また、Procedureが更新するテーブルを評価することで、Procedureと関連のあるテーブル定義、UDF、前処理済みデータ、メタ情報、Procedure自体、どこに問題があっても検知はできますが、原因調査は大変です。できるだけシンプルにできるよう、Procedrue全体ではなく、単体で評価できる仕組みを作ったり、依存関係は既存のOracleの依存関係をそのまま利用するようにしました。

パフォーマンス改善

ご紹介したとおり、LLM コンバージョンツールを用いて OraclePL/SQL を Databricks Procedure にコンバージョンしました。しかし、OLTP を前提として作られている OraclePL/SQL を OLAP の Databricks に移行するにあたり、パフォーマンス面が大きな課題となりました。詳細については、以下の記事内の「Databricks Procedure導入課題」をご確認ください。
case-k.hatenablog.com

学び

コンバージョンプロセス全体を管理し、改善サイクルを高速に回す重要性

コンバージョンプロセス全体を管理し、評価結果を適切なプロセスへフィードバックしながら、継続的に改善していく仕組みの重要性を改めて感じました。コンバージョン評価結果を「人間」が確認後、フィードバック先は多岐に渡るので、全体のプロセスを管理し、改善サイクルを高速に回すことが大切です。全体ではなく、一部のプロセスにしかフィードバックできないと厳しかったです。LLM と dbt の各種機能を活用することで、PL/SQL のコンバージョンからデータ評価までを自動化でき、改善サイクルを高速に回せるようになりました。また、Databricks Procedure がサポートされたことで、SQL ウェアハウスから直接実行できるようになった点も、改善サイクルの高速化に寄与しています。さらに、PL/SQL のコンバージョンからデータ比較評価までをローカル環境で実行、検証できる仕組みを整えたことも、改善サイクルを迅速に回すうえで大きく寄与したと感じています。

LLMの力とソフトウェアエンジニアリングの重要性

LLM の登場により、これまでは諦めていたようなマイグレーションも、現実的な工数でできる時代になったと感じます(大変なことには変わりありませんが)。フィードバックを反映していくことで、マニュアル修正なしでデータ比較テストをパスするものが増え、コンバージョンペースは加速的に上がっていきました。ただし、LLM はそのまま使うだけでは十分な精度を発揮できず、現時点ではコンテキストエンジニアリングといった、ソフトウェアエンジニアリングが不可欠であることも実感しました。LLM をマイグレーションプロセスに組み込み、工数削減につながるレベルの成果を出すためには、十分に高い精度が求められます(特にデータ比較において差分が発生すると、その原因調査が必要となり、全体の進行ペースが大きく低下しました)。全体設計からコンテキストエンジニアリング、評価基盤、フィードバック改善プロセスの運用設計などソフトウェアエンジニアとしての能力が重要に思いました。

OSSへのコントリビュート機会

今回のマイグレーションを通じて、OSS にコントリビューションする機会がありました。dbt Python モデルの検証を進める中で、Job のアクセスコントロールに問題があることが分かりました。また、Notebook と Job の権限を分けて制御できなかったので、両者を分離できるよう機能追加および修正を行いました。
github.com
また、Databricks のコンソールから実行中のジョブをキャンセルした場合でも、成功として扱われてしまう問題もありました。これに対しても修正のための Pull Request を作成し、マージしてもらいました。
github.com
マージされるまではdbt Pythonモデル内でAPIを使い、パッチ対応しました。Databricksは他のOSSも含めてレビュー速度が非常に速いと感じます。組織としてOSSに注力されているのを感じます。また、機会等あればコントリビュートしていきたいです。

エージェントを活用したデータ比較評価の更なる自動化

ご紹介したとおり、Procedure の単体テストは、「人間」がローカル環境から各 dbt コマンドを実行して行ってきました。評価担当者は、データ比較で差分が検知された場合に原因を調査し、修正内容を反映した Pull Request を作成します。レビュー担当者がその Pull Request の内容を確認し、前述のとおり適切なコンバージョンプロセスへフィードバックします。
しかし、Claude Codeなどをデータ比較評価エージェントとして活用すれば、データ比較評価でもさらに自動化できた可能性があります。dbt評価基盤や各APIを「ツール」として扱い、dbt コマンドの実行から、データ比較評価、テーブルやファイルアクセスの伴う差分の原因調査、原因調査後の修正と再評価、修正結果を反映したPull Request の作成まで実現できるように思います。各コマンドはローカル環境から実行可能であり、差分発生時の原因調査方法も「人間」がエージェントのプロンプトにフィードバックできます。今回は制約もありそこまで踏み込めなかったのですが、PL/SQLのコンバージョンだけではなく、データ比較評価でも、エージェントを活用することで、データ比較評価を更に自動化できたように思います。

まとめ

LLMコンバージョンツール(LLM処理・ルール処理)と dbt 評価基盤の紹介をしました。LLMが使えなかったらコンバージョンは難しかったです。ただし、LLM はそのまま使うだけでは十分な精度を発揮できず、履歴管理やルール処理を含めた コンテキストエンジニアリング といった、ソフトウェアエンジニアリングが不可欠であることも実感しました。また、コンバージョン結果を評価するために評価基盤の整備が必要です。dbt を活用することで、依存関係の制御、タグを用いた単体テストから結合テストまで実行単位の切り替え、特定の日付断面を用いたローカル環境からの実行、テーブル定義や評価用データの準備、dbt Audit Helperを用いたデータ比較も同一のインターフェース上で実現できました。最後に、今回のコンバージョンを通じて、コンバージョンプロセス全体を管理し、評価結果を適切なプロセスへフィードバックしながら、継続的に改善していく仕組みが非常に大切だと思いました。LLM時代に評価基盤はセットですね。最後まで読んで頂きありがとうございました。良いお年を。

Oracle PL/SQL を Databricks にマイグレーションするための Databricks Procedure 導入Tips

Databricks Advent Calendar 2025 17日目の記事です。Oracle PL/SQLをDatabricksにマイグレーションする機会があり、Databricks Procedureが非常に便利だったので、導入Tipsをご紹介できればと思います。Databricks Procedureの活用を検討されてる方の参考になれば幸いです。

Databricks Procedureとは

Databricks上で実行できるProcedureで、SQL Scriptingをパッケージングし、Unity Catalogで管理することができます。ループ処理や IF 文による条件分岐など、PL/SQLで行っていたような処理をそのまま表現でき、マルチステートメントトランザクション制御などにも対応しています。SQLで管理できる点も嬉しく、SQLエディタからSQLウェアハウスを利用した開発もできます。
CREATE PROCEDURE | Databricks on AWS
SQL scripting | Databricks on AWS

Databricks Procedure が解決するマイグレーションの課題

大規模かつ現役で変更が入り続けているOracle PL/SQLをリファクタし、SELECT 文に書き換えて移行するのはかなり厳しいです。
Databricks の Databricks Oracle Migration Guideにも現在はDatabricks Procedureへの移行も推奨していますが、従来はコンバージョンツールを使い、PySpark に変換する方法が推奨されていました。しかし、PySpark になってしまうとNotebook が必要になり、SQL で管理できなくなります。これまで Oracle をメンテナンスしてきたメンバーだと、Pythonの知見がなく、保守運用することも難しくなります。また、マルチステートメントトランザクション制御も DB 側ではなく、アプリケーション側で担保する必要がありました。
Databricks で Procedure が利用可能になったことで、SQL管理できるようになり、運用担当者への引き継ぎもしやすく、SQLエディタからSQLウェアハウスを使った開発も可能です。マルチステートメントトランザクション制御を含む、PL/SQLマイグレーションに必要な様々な機能がサポートされています。

Databricks Procedure がサポートされて助かったところ

自動コンバージョン:構文エラーの検知と改善サイクルの高速化

構文エラーがある場合、「CREATE PROCEDURE 」を実行するとすぐに 「Syntax Error」 として検知されます。そのため、PL/SQL を自動コンバージョンする際に、変換結果を SQLウェアハウスにAPIで流すことで、構文エラーの評価と改善のサイクルを高速に回すことができました。PySpark では、spark.sql 内で SQL を文字列として記述することが多く、変数等も入ってくると、実際にジョブとして流してみないと、構文エラーの検知が難しかったように思います。また、「Syntax Error」後のデータ比較でも直接SQLエディタからも検証できたので、改善サイクルを高速化できました。

自動コンバージョンの詳細については、アドベントカレンダー25日目の記事 「Oracle PL/SQL→Databricks マイグレーションで作った LLMコンバージョンツールと dbt 評価基盤の紹介」 で紹介しています。ご興味があればぜひご覧ください。
case-k.hatenablog.com

冪等化:Multi-statement transactions

「ATOMIC」オプションを付与することで、マルチステートメントトランザクション制御が可能です。この機能が使えない場合、アプリケーション側での制御が必要になりますが、DB側にトランザクション制御を任せることができるようになりました。バッチ処理ステートメントの途中で処理が落ちてしまっても、安全にリトライできます。

CREATE PROCEDURE procedure_name()
LANGUAGE SQL
SQL SECURITY INVOKER
AS BEGIN ATOMIC
  
END

まだ制限もあるため、トレードオフは考慮した方が良さそうです。例えば、「ATOMIC」オプションと例外処理「EXIT HANDLER」と併用することはできません。例外処理を使う場合、ラッパーとなる親のProcedureを作り、そこで例外処理と「ATOMIC」オプションを利用している、子Procedureの実行が必要になります。
UNSUPPORTED_FEATURE error condition | Databricks on AWS

※ 執筆時点ではまだPrivate Preview機能

同時実行制御:Session-local-temporary-tables

直接的なProcedureの機能ではないですが、Procedure内でSession-local-temporary-tablesを使うことで、セッション単位での一時的なテーブルを作成できます。一部、レポーティングツールを使い、複数のユーザーが同時実行しているProcedureがあります。PL/SQLでは同時実行制御に「global temporary table」を使い、対応していました。Session-local-temporary-tablesを使うことで、同時実行されても、競合やデータ不整合なく、一時テーブルへの書き込み処理が可能です。既存のPL/SQLの処理を大きく変えずに対応できるため助かりました。

CREATE temporary table temp_customers (
  id INT,
  name STRING
)

※ 執筆時点ではまだPrivate Preview機能

親子Procedureの分割実行

Procedure内で他のProcedureもCALL可能です。2万行を超えるような大規模な Procedure は、そのままでは大きすぎて各種リミットにも引っかかり、分割して対応する必要がありました。また、もともと親 Procedure から子 Procedure をCALLする構成になっていた処理も多く、その点は構造を保ったまま移行できます。(そのまま移行するかはプロジェクトの方針によります)先ほども触れましたが、「ATOMIC」オプションと例外処理「EXIT HANDLER」は併用できないため、Procedure を分割するなどの対応が必要になります。PySparkだった場合別途、新しいジョブを実行するなど必要で、Procedureのようにパッケージ化して呼び出せないと、難しかった部分なのではないかと思います。

CREATE PROCEDURE child_procedure()
LANGUAGE SQL
SQL SECURITY INVOKER
AS BEGIN ATOMIC
 
END

CREATE PROCEDURE parent_procedure()
LANGUAGE SQL
SQL SECURITY INVOKER
AS BEGIN
   CALL child_procedure()
END

PL/SQL互換のパラメータ

PL/SQL同様、Databricks Procedureでも入力専用のパラメータ(IN)、入力・出力の両方に使用できるパラメータ(INOUT)、出力専用のパラメータ(OUT)が使えます。PL/SQLでも使われているため、Procedureの呼び出しもとを大きく変えずに移行できたので助かりました。

-- Demonstrate INOUT and OUT parameter usage.
> CREATE OR REPLACE PROCEDURE add(x INT, y INT, OUT sum INT, INOUT total INT)
    LANGUAGE SQL
    SQL SECURITY INVOKER
    COMMENT 'Add two numbers'
    AS BEGIN
        SET sum = x + y;
        SET total = total + sum;
    END;
> DECLARE sum INT;
> DECLARE total INT DEFAULT 0;
> CALL add(1, 2, sum, total);
> SELECT sum, total;
 3 3
> CALL add(3, 4, sum, total);
 7 10

引用:https://docs.databricks.com/aws/en/sql/language-manual/sql-ref-syntax-ddl-create-procedure#examples

Databricks Procedureでの SELECT 結果取得

OraclePL/SQLだと、Procedure内のSELECT文実行結果を呼び出し元に返す場合、OUTパラメータとしてカーソルを返す必要があります。Databricksの場合はOUTパラメータの指定なしで、Procedureの最後に実行しているSELECT結果を返すようです。SELECT文以外も使えるテーブル関数のように利用できるので便利です。レポーティングツールなどで、ProcedureのSELECT結果を利用していたので、助かりました。Procedureがサポートされていなかったら、全てSELECT文のテーブル関数として、リファクタする必要があったと思います。

CREATE OR REPLACE PROCEDURE greeting(IN mode STRING COMMENT 'informal or formal')
LANGUAGE SQL
SQL SECURITY INVOKER
AS
BEGIN

  DECLARE msg STRING;

  SET msg = CASE
              WHEN mode = 'informal' THEN 'Hi!'
              WHEN mode = 'formal'   THEN 'Pleased to meet you.'
              ELSE 'Hello!'
            END;

  INSERT INTO greeting_log
  VALUES (mode, msg);

  MERGE INTO greeting_summary t
  USING (
    SELECT mode AS mode, COUNT(*) AS cnt
    FROM greeting_log
    WHERE mode = mode
    GROUP BY mode
  ) s
  ON t.mode = s.mode
  WHEN MATCHED THEN
    UPDATE SET cnt = s.cnt
  WHEN NOT MATCHED THEN
    INSERT (mode, cnt)
    VALUES (s.mode, s.cnt);

  SELECT
    mode        AS input_mode,
    msg         AS greeting_message;

END;
> CALL greeting('informal');
informal,Hi! 

https://docs.databricks.com/aws/en/sql/language-manual/sql-ref-syntax-ddl-create-procedure#examples

Databricks Procedure導入課題

新しい機能だったので導入にあたり不安はあったのですが、機能要件は全て満たすことができました。一方で、パフォーマンス面は問題となり、リファクタリングが必要なところもありました。

パフォーマンスの劣化

Databricks Procedureのまま移行したことで、パフォーマンスは問題となりました。

ループ処理対応

SQL Scripting でもループ処理はサポートされていますが、Oracle とは異なりインデックスが効かないため、Oracle では問題にならなかった「 N+1 問題」系のクエリは軒並みパフォーマンス問題に直面しました。 その結果、処理時間の大幅な悪化により処理が終わらず、ほとんどはループを使わず、JOIN等でリファクタしました。

全体的なパフォーマンス劣化

Oracle のテーブルは全体的にデータ量が少なく、インデックスも有効に機能していたため、高速に処理できていました。一方で、PL/SQLをDatabricks Procedure に移行した場合、ループだけでなく、読み取りや書き込み系の処理含め、全体的にパフォーマンスは劣化しました。Oracleのテーブルは100MBにも満たないスモールファイルも多く、リキッドクラスタリング等ファイルスキップでの高速化はできませんでした。バッチ系の処理についてはループ処理以外は大きな問題にはなりませんでしたが、レポーティングツールなどから同時実行され、リアルタイム性が求められるProcedure ではパフォーマンス劣化に伴う調整やOracle と同等のパフォーマンスを出すには事前計算し、モデリングが必要となりました。

Databricks Procedure関連で改善されると助かるところ

パフォーマンス問題

Databricksの設計的にに難しいことだとは思うのですが、Databricks Procedureマイグレーションした場合、同じ悩みにぶつかるように思います。ループ処理等許容できないレベル(1~10h)で遅くなってしまうため、複雑な処理をリファクタなしでマイグレーションできると、助かる方は非常に多くいるように思いました。

ODBCドライバのDatabricks Procedureサポート

VBAで作られているレポーティングツールからProcedureを実行する際にODBCドライバの利用を検討しました。検証したところ、ODBCドライバでProcedureを実行することはできましたが、Procedure内で実行したSELECT結果を取得することはできませんでした。

CREATE PROCEDURE | Databricks on AWS
ODBCドライバで取得するには一度テーブルとして書き出す必要があります。書き込んだテーブルを別途取得するという 多段のオペレーションが必要になり、すでにパフォーマンス課題を抱えている状況で、さらに処理が遅くなってしまうため断念しました。代わりの方法としてStatement Execution APIを利用することにしました。しかし、Statement Execution APIの場合、セッションが維持されないため、ODBCドライバで使えたSETなどは使えませんでした。また、Statement Execution APISQLウェアハウスに対してのみ使えるため、汎用クラスターなどは使えません。SQLウェアハウスのSpark Configを利用する場合、ワークスペースレベルでの変更が必要になるため、影響範囲を考えると難しいです。そのため、SETで対応予定だったタイムゾーンの制御を断念し、Procedure側で吸収することにしました。ODBCドライバのサポート、もしくはSQLウェアハウス単位でSpark Config設定ができると便利だと思いました。

UDFの上限緩和 or Oracle互換のUDF提供

直接Procedureとは関係ないのですが、マイグレーションを進める上で、Oracle互換のUDFをいくつか作りました。ProcdureからUDFの上限5つを超えてしまうことがあり、Spark Config側に以下の設定を加えました。基本的にSQLウェアハウスのリソースを使っていましたが、SQLウェアハウスは先ほども触れた通り、ワークスペースレベルでの適用になってしまうため、汎用クラスターで対応しました。UDFの上限緩和、もしくはSQLウェアハウス単位で、Spark Config設定ができると便利だと思いました。理想的にはOracle互換のUDFが提供されてると嬉しいです。

spark.databricks.safespark.externalUDF.plan.limit 30
spark.databricks.safespark.sandbox.trackMemory.enabled false
spark.databricks.safespark.sandbox.size.default.mib 500
[UDF_MAX_COUNT_EXCEEDED] Exceeded query-wide UDF limit of 5 UDFs

Solved: [UDF_MAX_COUNT_EXCEEDED] Exceeded query-wide UDF l... - Databricks Community - 38120

SQLウェアハウス単位でのSpark Config設定

SQL ウェアハウス単位で Spark Config を設定できるようになると、Statement Execution APIでSETが使えない問題やUDFの上限緩和問題も解決するので、ぜひサポート頂きたい機能だと思いました。
SQL warehouse admin settings | Databricks on AWS

Atomicオプションと例外処理の併用

PL/SQL内では全て例外処理「EXIT HANDLER」を定義しており、エラーメッセージ等をテーブルに書き込んでいます。「Atomic」オプションを付与してしまうと、例外処理を使うことができなくなるため、Procedureの親子に分割して実行する必要がありました。「Atomic」オプションの導入メリットが大きかったので、分割しましたが、出来れば分割なしで実行できるようサポート頂けると嬉しいです。

まとめ

DatabricksでProcedureがサポートされたことで、マイグレーションが非常に楽になりました。一方で、DatabricksでProcedureへ移行する際にループ処理を含むパフォーマンスの問題はみなさんぶつかるところだと思うので、何か良いソリューションが出てくると嬉しいです。最後まで読んでいただきありがとうございました。

API GatewayとVPCエンドポイントを活用した、プライベートネットワークからパブリックネットワークへの接続手法

本稿では、API Gatewayをプロキシサーバとして活用し、プライベートネットワークからパブリックネットワークへ接続する方法をご紹介します。一般的にはNAT Gatewayを利用する構成が多いですが、セキュリティ要件などによりインターネットへのアクセスを厳格に管理する必要がある場合、API Gatewayの活用が有効です。また、最近ではLLMが注目される中、API Gatewayの利用クオータが引き上げられました。今後このような活用事例も増えてくるかもしれません。
aws.amazon.com

また、先日投稿した記事もLLMのサービングに関連する記事です。
www.case-k.jp

本記事では詳細な説明は省略いたしますが、Terraformによる定義例を共有いたします。同様の活用事例を検討される方の参考になれば幸いです。

Terraform

ネットワークリソース

まず、ネットワークリソースについて簡単に説明いたします。今回扱うVPCのリソースマップは以下の通りです。

図から、ネットワーク接続にインターネットゲートウェイやNAT Gatewayが存在しないことが確認できます。
これは、API Gatewayをプロキシサーバとしてインターナルアクセスする場合、これらのリソースを自前で用意する必要がないためです。
なお、参考までに、NAT Gatewayを用いた構成例は以下のようになります。

まずはネットワークリソースの作成から始めます。VPC環境からAPI Gatewayへのインターナルアクセスを実現するため、VPCエンドポイントを活用します。

resource "aws_vpc" "vpc" {
  cidr_block = "10.0.0.0/16"
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = {
    "Name" = "vpc-for-api-gateway"
  }
  tags_all = {
    "Name" = "vpc-for-api-gateway"
  }
}

# resource "aws_subnet" "aws_subnet_private" {
resource "aws_subnet" "subnet_private" {
  vpc_id     = aws_vpc.vpc.id
  cidr_block = "10.0.1.0/24"
  tags = {
    "Name" = "private-subnet-for-api-gateway"
  }
  tags_all = {
    "Name" = "private-subnet-for-api-gateway"
  }
}

ingressにはself = trueを設定し、同じセキュリティグループ内のリソース同士で通信できるようになります。NAT Gateway には不要ですが、API GatewayVPC エンドポイントアクセスなど、セキュリティグループ内のリソース間で HTTPS 通信が必要な場合に有用です。アウトバウンドのトラフィックは全て許可します。

resource "aws_security_group" "security_group" {
  vpc_id = aws_vpc.vpc.id

  # ingress does not need for nat gateway but need for API gateway VPC endpoint access
  ingress {
    from_port = 443
    to_port   = 443
    protocol  = "tcp"
    self      = true
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    "Name" = "sg-private-subnet-for-api-gateway"
  }
  tags_all = {
    "Name" = "sg-private-subnet-for-api-gateway"
  }
}

以下のコードは、Terraform を利用してプライベート API Gateway 用のルートテーブルおよび VPC エンドポイントを構築する例です。インターフェース型エンドポイントは、AWS PrivateLink を利用してプライベートにサービスに接続するためのものです。API Gatewayへのインターナル接続で利用します。

resource "aws_route_table" "private-route" {
  propagating_vgws = []
  tags = {
    Name = "private-route-for-api-gateway"
  }
  tags_all = {
    Name = "private-route-for-api-gateway"
  }
  vpc_id = aws_vpc.vpc.id
}

resource "aws_route_table_association" "route_table_association" {
  subnet_id      = aws_subnet.subnet_private.id
  route_table_id = aws_route_table.private-route.id
}

# ref
# https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/apigateway-private-api-create.html
resource "aws_vpc_endpoint" "vpc_endpoint" {
  vpc_id            = aws_vpc.vpc.id
  service_name      = "com.amazonaws.ap-northeast-1.execute-api"
  vpc_endpoint_type = "Interface"

  subnet_ids = [
    aws_subnet.subnet_private.id
  ]

  security_group_ids = [
    aws_security_group.security_group.id,
  ]

  private_dns_enabled = true

  tags = {
    Name = "vpc-endpoint-for-api-gateway"
  }
  tags_all = {
    Name = "vpc-endpoint-for-api-gateway"
  }
}

API Gateway

以下はAPI Gatewayの定義例です。詳細は省略いたしますが、API Gatewayをプロキシサーバとして活用する構成となっています。APIエンドポイントのタイプはプライベートに設定し、VPCエンドポイントから接続できるようにしています。デプロイ後、API Gatewayのエンドポイントにリクエストを送ると、指定したエンドポイントへアクセスが可能です。

REST API の基本情報(名前やAPIキーの受け取り方法など)を設定し、リソースポリシーで「aws:SourceVpc」が指定の VPC と一致する場合のみ呼び出しを許可する条件を付与しています。また、エンドポイントを PRIVATE に設定し、特定の VPC エンドポイント(aws_vpc_endpoint.vpc_endpoint.id)と連携させています。

resource "aws_api_gateway_rest_api" "api_gateway_rest_api" {
  api_key_source               = "HEADER"
  binary_media_types           = []
  body                         = null
  description                  = null
  disable_execute_api_endpoint = false
  fail_on_warnings             = null
  minimum_compression_size     = null
  name                         = "api-private-gw"
  parameters                   = null
  # policy                       = null
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect    = "Allow"
        Principal = "*"
        Action    = "execute-api:Invoke"
        Resource  = "execute-api:/*"
        # Resource  = "arn:aws:execute-api:ap-northeast-1:132483466678:l9jk54cpy0/*"
        Condition = {
          StringEquals = {
            "aws:SourceVpc" = aws_vpc.vpc.id
          }
        }
      }
    ]
  })
  put_rest_api_mode = "overwrite"
  tags              = {}
  tags_all          = {}
  endpoint_configuration {
    types            = ["PRIVATE"]
    vpc_endpoint_ids = [aws_vpc_endpoint.vpc_endpoint.id]
  }
}

API のルートリソース直下に、パスパラメータ "{proxy+}" を持つリソースを作成します。

resource "aws_api_gateway_resource" "api_gateway_resource" {
  depends_on = [aws_api_gateway_rest_api.api_gateway_rest_api]
  # parent_id   = aws_api_gateway_resource.api_gateway_resource_parent.id
  parent_id   = aws_api_gateway_rest_api.api_gateway_rest_api.root_resource_id
  path_part   = "{proxy+}"
  rest_api_id = aws_api_gateway_rest_api.api_gateway_rest_api.id
}

上記リソースに対して HTTP のすべてのメソッド(ANY)を許可し、APIキー認証を必須としています。リクエストパラメータとして URL のパス部分を必須に設定。

resource "aws_api_gateway_method" "api_gateway_method" {
  depends_on           = [aws_api_gateway_resource.api_gateway_resource]
  api_key_required     = true
  authorization        = "NONE"
  authorization_scopes = []
  authorizer_id        = null
  http_method          = "ANY"
  operation_name       = null
  request_models       = {}
  request_parameters = {
    "method.request.path.proxy" = true
  }
  request_validator_id = null
  resource_id          = aws_api_gateway_resource.api_gateway_resource.id
  rest_api_id          = aws_api_gateway_rest_api.api_gateway_rest_api.id
}

メソッド呼び出し成功時(200)のレスポンスモデルを定義。

resource "aws_api_gateway_method_response" "api_gateway_method_response" {
  rest_api_id = aws_api_gateway_rest_api.api_gateway_rest_api.id
  resource_id = aws_api_gateway_resource.api_gateway_resource.id
  http_method = aws_api_gateway_method.api_gateway_method.http_method
  status_code = "200"
  response_models = {
    "application/json" = "Empty"
  }
}

バックエンドからのレスポンスを、JSON テンプレート(空のスキーマ)に変換する設定を行っています。

resource "aws_api_gateway_integration_response" "api_gateway_integration_response" {
  rest_api_id = aws_api_gateway_rest_api.api_gateway_rest_api.id
  resource_id = aws_api_gateway_resource.api_gateway_resource.id
  http_method = aws_api_gateway_method.api_gateway_method.http_method
  status_code = aws_api_gateway_method_response.api_gateway_method_response.status_code
  response_templates  = {
    "application/json" = jsonencode(
          {
            "$schema" = "http://json-schema.org/draft-04/schema#"
            title     = "Empty Schema"
            type      = "object"
          }
      )
  }
}

API の呼び出しに必要な API キー(ここでは "test-key")を作成。

resource "aws_api_gateway_api_key" "api_gateway_api_key" {
  customer_id = null
  description = null
  enabled     = true
  name        = "test-key"
  tags        = {}
  tags_all    = {}
  value       = null # sensitive
}

1日あたりのリクエスト上限(クォータ)やスロットリング(バースト・レート制限)を設定し、対象の API ステージ("test")を紐付けています。

resource "aws_api_gateway_deployment" "api_gateway_deployment" {
  depends_on        = [aws_api_gateway_method.api_gateway_method]
  description       = null
  rest_api_id       = aws_api_gateway_rest_api.api_gateway_rest_api.id
  stage_description = null
  stage_name        = null
  triggers          = null
  variables         = null
}

作成した API キーと使用量プランを関連付け、API キー利用者に対して制限を適用します。

resource "aws_api_gateway_stage" "api_gateway_stage" {
  cache_cluster_enabled = false
  cache_cluster_size    = null
  client_certificate_id = null
  deployment_id         = aws_api_gateway_deployment.api_gateway_deployment.id
  description           = null
  documentation_version = null
  rest_api_id           = aws_api_gateway_rest_api.api_gateway_rest_api.id
  stage_name            = "test"
  tags                  = {}
  tags_all              = {}
  variables             = {}
  xray_tracing_enabled  = false
}

resource "aws_api_gateway_usage_plan" "api_gateway_usage_plan" {
  description  = null
  name         = "test-gw-plan"
  product_code = null
  tags         = {}
  tags_all     = {}
  api_stages {
    api_id = aws_api_gateway_rest_api.api_gateway_rest_api.id
    stage  = aws_api_gateway_stage.api_gateway_stage.stage_name
  }
  quota_settings {
    limit  = 20
    offset = 0
    period = "DAY"
  }
  throttle_settings {
    burst_limit = 5
    rate_limit  = 5
  }
}

resource "aws_api_gateway_usage_plan_key" "api_gateway_usage_plan_key" {
  key_id        = aws_api_gateway_api_key.api_gateway_api_key.id
  key_type      = "API_KEY"
  usage_plan_id = aws_api_gateway_usage_plan.api_gateway_usage_plan.id
}

定義したメソッド(ANY)に対して、HTTP プロキシ統合を設定し、受け取ったリクエストを https://httpbin.org/get に転送します。キャッシュのキーとしてパスパラメータを利用し、タイムアウトなどの挙動も指定しています。

resource "aws_api_gateway_integration" "api_gateway_integration" {
  cache_key_parameters    = ["method.request.path.proxy"]
  cache_namespace         = aws_api_gateway_resource.api_gateway_resource.id
  connection_id           = null
  connection_type         = "INTERNET"
  content_handling        = null
  credentials             = null
  http_method             = "ANY"
  integration_http_method = "GET"
  passthrough_behavior    = "WHEN_NO_MATCH"
  request_parameters = {
    "integration.request.path.proxy" = "method.request.path.proxy"
  }
  request_templates    = {}
  resource_id          = aws_api_gateway_resource.api_gateway_resource.id
  rest_api_id          = aws_api_gateway_rest_api.api_gateway_rest_api.id
  timeout_milliseconds = 5000
  type                 = "HTTP"
  uri                  = "https://httpbin.org/get"
}

Lambda

疎通確認用にLambdaをVPCにデプロイします。

import requests
import json


def lambda_handler(event, context):
    # ref
    # url = "https://httpbin.org/get"
    url = "https://<api-gw-id>.execute-api.ap-northeast-1.amazonaws.com/test/{proxy+}"
    
    # APIキーを指定
    api_key = "api-key"

    headers = {
        "x-api-key": api_key
    }
    
    response = requests.get(url, headers=headers)
    
    print(response.text)
    
    return {
        "statusCode": response.status_code,
        "body": response.text
    }

Terraform

data "archive_file" "lambda_zip" {
  type        = "zip"
  source_dir  = "../app/lambda/python/package"
  output_path = "../app/lambda/python/lambda_function.zip"
}

resource "aws_lambda_function" "lambda_function_for_api_gateway" {
  depends_on       = [data.archive_file.lambda_zip]
  function_name    = "lambda_function_for_api_gateway"
  role             = aws_iam_role.lambda_sample_function.arn
  handler          = "lambda_function.lambda_handler"
  runtime          = "python3.11"
  memory_size      = 128
  timeout          = 5
  source_code_hash = filebase64sha256(data.archive_file.lambda_zip.output_path)
  filename         = data.archive_file.lambda_zip.output_path
  vpc_config {
    subnet_ids         = [aws_subnet.subnet_private.id]
    security_group_ids = [aws_security_group.security_group.id]
  }
}

疎通確認

VPCにデプロイ済みのLambda関数をテスト実行し、プライベートネットワークからAPI Gatewayをプロキシサーバとして経由し、パブリックネットワークへ接続できるか確認します。


以上となります。

terraform-provider-awsにコントリビュートする方法:Issueの起票からコントリビュートまで

terraform-provider-awsにコントリビュートする機会があったので、その過程を備忘録として記録しておきたいと思います。コントリビュートした内容としてはEventBridgeへの強制削除オプションの追加です。EventBridgeを触っている時に見つけたもので、issueを起票してコントリビュートしました。terraform-provider-awsへのコントリビュートを考えている方の参考になれば幸いです。
github.com

Issueの見つけ方

仕様の違和感に気づく

TerraformでCI/CDを構築する際、デプロイしたEventBridgeを削除しようとしたところ、以下のエラーが発生しました。EventBridgeのターゲットが存在している場合、強制的に削除ができない仕様のようです。この制約は不便に感じたため、APIの仕様を確認しました。私の場合はこのような違和感が、コントリビュートのきっかけになることが多いように思います。

│ Error: deleting EventBridge Rule (): ValidationException: Rule can't be deleted since it has targets.
│       status code: 400, request id: <resource_id>

また、すでに多くのissueが起票されているので、もし興味のあるissueがあればここから探せます。

github.com

AWS API Referenceを確認する

APIで実装されていない機能はTerraformでも利用できないため、まずはEventBridgeのAPI仕様を確認します。今回はEventBridgeの強制削除がサポートされていることを確認しました。
docs.aws.amazon.com

Terraformのドキュメントを確認する

Terraformのドキュメントを確認し、EventBridgeにおける強制削除オプションがサポートされているか確認しました。今回はEventBridgeの強制削除オプションが未実装であることを確認しました。
github.com

Issueを起票する

Terraformで強制削除オプションがサポートされていないことを確認したため、Issueを起票しました。OSSによっては、Issueを作成する際に「Would you like to implement a fix?(修正を実装したいですか?)」という確認項目が含まれる場合があります。この場合、修正を自分で行いたい場合は、その旨を記載して意思を示します。

github.com

逆にそのような項目がないOSSに対してはテンプレートに項目を追加するのはどうか聞いてみるといいと思います。
過去に別のOSSで提案して、追加してもらいました。
github.com

コントリビュートする方法

Contributing Guideを確認する

READMEに記載されているContributing Guideを確認します。また、過去にマージされたPull Requestも参照し、コミュニティでの一般的なお作法や手順を把握します。確認すると、Pull Requestにはテストの実行結果を添付する必要があることが分かります。

hashicorp.github.io

Terraformのコードを確認してPRを作成する

リポジトリをフォークし、ブランチを切って、PRを作成します。この流れはどのOSSでもほとんど同じように思います。ブランチを切ったら、問題となる箇所を特定します。私の場合、まずは既存のテストコードを実行できるように環境を整備します。既存のテストコードを動かすと、処理の全体の流れが掴めます。修正すべき該当箇所が特定できるので、必要な修正を加えた後にPull Requestを作成します。

レビューしてもらう

Pull Request作成後はメンテナの反応を待ちます。個人的にはterraform-provider-awsは人手不足なのか少し反応が遅いように感じました。この辺はOSSによって随分違います。

まとめ

terraform-provider-awsにコントリビュートしするまでの大まかな流れを紹介しました。私の場合、実際に触ってみて違和感を感じた箇所やバグを修正する形でコントリビュートすることが多いです。GitHub上でIssueを確認したり、未解決の課題を探したりすると、さらに多くのコントリビュート機会ができると思います。現時点ではそこまで積極的にやれてないですが、今年はもう少し挑戦したいと思います。

Databricksでモデルサービングを迅速にデプロイするNotebook運用

本記事は、 Databricks - Qiita Advent Calendar 2024 - Qiitaシリーズ 2 の 25 日目の記事です。

モデルサービングをデプロイする際に使用しているNotebookテンプレートの運用をご紹介します。

モデルサービングはTerraformでデプロイすることも可能です。しかし、モデルの運用(再学習)などを考慮すると、Terraformで管理しているモデルのバージョンと実際に利用されているモデルのバージョンが一致しない場合があります。そこで、TerraformではNotebook Jobのみを管理し、Notebook内でモデルサービングのデプロイを行う運用を採用しています。

また、モデルサービングをデプロイする際には、モデルのロジック部分以外を共通化することでテンプレートとして管理しています。このテンプレートを利用することで、モデルの作成者はロジック部分のみを編集するだけで簡単にモデルサービングをデプロイできます。

docs.databricks.com

Notebookテンプレート

以下のようなテンプレートを作成しています。「EDITABLE 」となっている箇所がモデル作成者側で修正する箇所になります。利用者はテンプレートをコピーし、「EDITABLE 」となっているモデルのロジック部分を修正します。「EDITABLE 」の前後にある「pre_hook」と「post_hook」は共通処理となります。後ほど紹介しますが、Notebookに渡されたパラメータに基づいてモデルやモデルサービングの登録や更新、削除などの処理をしています。「EDITABLE」となっている箇所も別ファイルとして管理した方がテンプレートはシンプルになりますが、Databricks上でモデルの実行結果等可視化して確認しやすいようこのような運用を採用しています。

# Databricks notebook source

# MAGIC %run ./model_serving

# COMMAND ----------
import json
import mlflow
mlflow.set_registry_uri("databricks-uc")

# COMMAND ----------

params_string = dbutils.widgets.get("params")
params = json.loads(params_string)
print(f'params: {params}')


# COMMAND ----------

# MAGIC %python
# MAGIC pre_hook(params)

# COMMAND ----------
################ EDITABLE ################ 
################ START ################ 
from sklearn import datasets
from sklearn.ensemble import RandomForestClassifier

model = params['model']
artifact_path = model['artifact_path']
endpoint = params['endpoint']
model_name = endpoint['config']['served_entities'][0]['entity_name']

experiment_path = model['experiment_path']
mlflow.set_experiment(experiment_path)

with mlflow.start_run():
    # Train a sklearn model on the iris dataset
    X, y = datasets.load_iris(return_X_y=True, as_frame=True)
    clf = RandomForestClassifier(max_depth=7)
    clf.fit(X, y)
    # Take the first row of the training dataset as the model input example.
    input_example = X.iloc[[0]]
    # Log the model and register it as a new version in UC.
    mlflow.sklearn.log_model(
        sk_model=clf,
        artifact_path=artifact_path,
        # The signature is automatically inferred from the input example and its predicted output.
        input_example=input_example,
        registered_model_name=model_name,
    )
################ END ################ 

# COMMAND ----------

# MAGIC %python
# MAGIC post_hook(params)

次にテンプレートで参照している「pre_hook」と「post_hook」について紹介します。以下のコードを見るとわかりますが、Notebookのパラメータに基づいてモデルやモデルサービングの作成や更新、削除等を行っています。「pre_hook」と「post_hook」の処理から見ていただくと雰囲気が掴めると思います。

# Databricks notebook source

from mlflow.deployments import get_deploy_client
from mlflow.tracking import MlflowClient
import time
from distutils.util import strtobool


# COMMAND ----------

def pre_hook(params):
    model = params['model']
    force_delete = bool(strtobool(params['force_delete']))
    endpoint = params['endpoint']
    endpoint_name = endpoint['endpoint_name']
    config = endpoint['config']
    model_name = config['served_entities'][0]['entity_name']
    model_version = config['served_entities'][0]['entity_version']
    print(f'codfig: {config}')
    print(f'force_delete: {force_delete}')
    if model_registered_exists(model_name) and force_delete:
        delete_registered_model(model_name)
    print('skip delete_registered_model')
    if endpoint_exists(endpoint_name) and force_delete:
        delete_model_serving_endpoint(endpoint_name)
        dbutils.notebook.exit("Model serving endpoint deleted. Exiting the notebook.")
    print('skip delete_model_serving_endpoint')
    if model_registered_exists(model_name) and endpoint_exists(endpoint_name) and model_version != 'latest':
        update_model_serving_endpoint(endpoint_name, config)
        wait_for_endpoint_ready(endpoint_name)
        dbutils.notebook.exit(f'Updated model endpoint version: {model_version}. Exiting the notebook')
    print('skip update_model_serving_endpoint')

def post_hook(params):
    endpoint = params['endpoint']
    endpoint_name = endpoint['endpoint_name']
    config = endpoint['config']
    model_name = config['served_entities'][0]['entity_name']
    model_version = config['served_entities'][0]['entity_version']
    print(f'codfig: {config}')

    if model_version == 'latest':
        model_version = get_model_registered_latest_version(model_name)
        config['served_entities'][0]['entity_version'] = model_version
    print(f'model version: {model_version}')
    # Check if the endpoint exists
    if endpoint_exists(endpoint_name):
        print(f"Endpoint '{endpoint_name}' exists.")
        wait_for_endpoint_ready(endpoint_name)
        print(f'update_model_serving_endpoint')
        update_model_serving_endpoint(endpoint_name, config)
        
    else:
        print(f"Endpoint '{endpoint_name}' does not exist.")
        print(f'create_model_serving_endpoint')
        create_model_serving_endpoint(endpoint_name, config)

    wait_for_endpoint_ready(endpoint_name)
    print('end')

# Model Registerd
def delete_registered_model(model_name):
    try:
        client = MlflowClient()
        client.delete_registered_model(name=model_name)
        print(f"Model '{model_name}' has been deleted.")
    except Exception as e:
        print(f"Error deleting model '{model_name}': {e}")
        raise

    
def model_registered_exists(model_name):
    try:
        client = MlflowClient()
        registered_models = client.search_registered_models()
        return any(model.name == model_name for model in registered_models)
    except Exception as e:
        print(f"Error checking if model '{model_name}' is registered in Unity Catalog: {e}")
        return False

def get_model_registered_latest_version(model_name):
    try:
        client = MlflowClient()
        print(f'model_name:{model_name}')
        client = MlflowClient()
        model_version_infos = client.search_model_versions("name = '%s'" % model_name)
        print(f'model_version_infos:{model_version_infos}')
        latest_version = max([int(model_version_info.version) for model_version_info in model_version_infos])
        return latest_version
    except Exception as e:
        print(f"Error get_model_registered_latest_version: {e}")
        raise


# Model serving
def delete_model_serving_endpoint(endpoint_name):
    try:
        deploy_client = get_deploy_client("databricks")
        deploy_client.delete_endpoint(endpoint=endpoint_name)
        print(f"Model serving endpoint '{endpoint_name}' has been deleted.")
    except Exception as e:
        print(f"Error deleting model serving endpoint '{endpoint_name}': {e}")
        raise

def create_model_serving_endpoint(name, config):
    try:
        deploy_client = get_deploy_client("databricks")
        deploy_client.create_endpoint(name=name,config=config)
        print(f"Model serving endpoint '{name}' has been created. config: {config}")
    except Exception as e:
        print(f"Error createing model serving endpoint '{name}': {e}")
        raise

def update_model_serving_endpoint(endpoint_name, config):
    try:
        deploy_client = get_deploy_client("databricks")
        deploy_client.update_endpoint(endpoint=endpoint_name,config=config)
        print(f"Model serving endpoint '{endpoint_name}' has been updated. config: {config}")
    except Exception as e:
        print(f"Error updating model serving endpoint '{endpoint_name}': {e}")
        raise

def endpoint_exists(endpoint_name):
    try:
        deploy_client = get_deploy_client("databricks")
        endpoints = deploy_client.list_endpoints()
        return any(endpoint['name'] == endpoint_name for endpoint in endpoints)
    except Exception as e:
        print(f"Error checking if endpoint exists '{endpoint_name}': {e}")
        return False

def get_endpoint_status(endpoint_name):
    try:
        deploy_client = get_deploy_client("databricks")
        endpoint_status = deploy_client.get_endpoint(endpoint=endpoint_name)
        return endpoint_status
    except Exception as e:
        print(f"Error getting status for endpoint '{endpoint_name}': {e}")
        raise

def wait_for_endpoint_ready(endpoint_name, timeout=1000, interval=30):
    start_time = time.time()
    while time.time() - start_time < timeout:
        try:
            # https://docs.databricks.com/api/workspace/servingendpoints/get
            status = get_endpoint_status(endpoint_name)
            if status['state']['ready'] == 'READY':
                print(f"Endpoint '{endpoint_name}' is ready.")

            if status['state']['config_update'] == 'NOT_UPDATING':
                print(f"Endpoint '{endpoint_name}' is currently NOT_UPDATING.")
                return
            else:
                print(f"Endpoint '{endpoint_name}' is currently being updated. Waiting...")
        except Exception as e:
            if "RESOURCE_CONFLICT" in str(e):
                print(f"Endpoint '{endpoint_name}' is currently being updated. Waiting...")
            else:
                raise
        time.sleep(interval)
    raise TimeoutError(f"Endpoint '{endpoint_name}' is not ready after {timeout} seconds.")

Databricks Job(Terraform)

Notebook JobはTerraformを使用してデプロイしており、パラメータに関する情報は以下のYAMLファイルに定義されています。このYAMLの設定に基づき、TerraformでNotebook Jobを動的にデプロイしています。

jobs:
  wf_test_model_endpoint_tmpl:
    name: wf_test_model_endpoint_tmpl
    domain_tag: test
    notebook_path: "notebook/model_serving/model_endpoint.tmpl"
    job_params:
      {
        "force_delete": "false",
        "model":
          {
            "experiment_path": "/Shared/common_model_experiments/sample",
            "artifact_path": "model",
          },
        "endpoint":
          {
            "endpoint_name": "workspace-model-endpoint",
            "config": { "served_entities": [
                    {
                      "name": "iris_model_serving",
                      "entity_name": "${Env}_data_science.<project>.sample_model",
                      "entity_version": "latest", # latest or version number for rollback
                      "workload_size": "Small",
                      "scale_to_zero_enabled": "true",
                    },
                  ], "auto_capture_config": { "catalog_name": "${Env}_catalog", "schema_name": "sample_schema, "enabled": "true" } },
          },
      }
    clusters:
      {
        spark_version: "15.3.x-cpu-ml-scala2.12",
        node_type_id: "i3.2xlarge",
        driver_node_type_id: "i3.2xlarge",
        autoscale: { min_workers: 2, max_workers: 10 },
      }
    template_path: "../jobs/template/model_serving_template.json"
    git_url: "https://git-codecommit.ap-northeast-1.amazonaws.com/v1/repos/<repository_name>"

モデルサービング用に作成したNotebook Jobのテンプレートは以下のとおりです。このテンプレートでは、YAMLで定義されたパラメータを活用して動的にJobの定義を構築しています。

{
    "name": "${name}",
    "email_notifications": {
        "no_alert_for_skipped_runs": false
    },
    "notification_settings": {
        "no_alert_for_canceled_runs": true
    },
    "webhook_notifications": {},
    "timeout_seconds": 0,
    "max_concurrent_runs": 1,
    "tags": {
        "product": "${domain_tag}"
    },
    "parameters": [
        {
            "name": "params",
            "default": "${params}"
        }
    ],
    "job_clusters": [
        {
            "job_cluster_key": "job_cluster_key_${env}",
            "new_cluster": {
                "spark_version": "${spark_version}",
                "node_type_id": "${node_type_id}",
                "driver_node_type_id": "${driver_node_type_id}",
                "policy_id": "${policy_id}",
                "autoscale": {
                    "min_workers": "${min_workers}",
                    "max_workers": "${max_workers}"
                },
                "aws_attributes": {
                    "first_on_demand": "${first_on_demand}"
                }
            }
        }
    ],
    "tasks": [
        {
            "task_key": "deploy_model_serving",
            "max_retries": 0,
            "notebook_task": {
                "notebook_path": "${notebook_path}",
                "source": "GIT"
            },
            "job_cluster_key": "job_cluster_key_${env}",
            "libraries": [
                {
                    "pypi": {
                        "package": "mlflow-skinny[databricks]>=2.5.0"
                    }
                }
            ]
        }
    ],
    "git_source": {
        "git_url": "${git_url}",
        "git_provider": "awscodecommit",
        "git_tag": "t_${env}"
    },
    "format": "MULTI_TASK"
}

動的に構築されたJobの情報は、以下のTerraformで定義されたJobに渡されています。この仕組みでは、モデルサービング以外のジョブリソースも含めて共通の定義を活用し、効率的な管理を実現しています。各ジョブの差分はlocalsで吸収し、local.job-association-mapを用いて全てのジョブリソース情報を一元的に受け取る構造となっています。

resource "databricks_job" "job" {
  for_each = local.job-association-map
  depends_on = [
    databricks_cluster.shared_dbx_cluster
  ]
  name                = each.value.name
  timeout_seconds     = each.value.timeout_seconds
  max_concurrent_runs = each.value.max_concurrent_runs
  git_source {
    url      = lookup(each.value, "git_source", null) != null ? each.value.git_source.git_url : null
    provider = lookup(each.value, "git_source", null) != null ? each.value.git_source.git_provider : null
    tag      = lookup(each.value, "git_source", null) != null ? each.value.git_source.git_tag : null
  }
  tags = {
    product = lookup(each.value, "tags", null) != null ? each.value.tags.product : local.tags.product
  }
  email_notifications {
    no_alert_for_skipped_runs = lookup(each.value.email_notifications, "no_alert_for_skipped_runs", null) != null ? each.value.email_notifications.no_alert_for_skipped_runs : null
    on_start                  = lookup(each.value.email_notifications, "on_start", []) != [] ? each.value.email_notifications.on_start : []
    on_success                = lookup(each.value.email_notifications, "on_success", []) != [] ? each.value.email_notifications.on_success : []
    on_failure                = lookup(each.value.email_notifications, "on_failure", []) != [] ? each.value.email_notifications.on_failure : local.on_failure
  }
  format = each.value.format

  dynamic "trigger" {
    for_each = { for key, val in each.value :
    key => val if key == "trigger" && val != null }
    content {
      pause_status = trigger.value.pause_status
      file_arrival {
        url = trigger.value.file_arrival.url
      }
    }
  }

  # use existing cluster instead of new_cluster.This will be used for IDBCDB,To import existing resources. 
  dynamic "job_cluster" {
    for_each = { for key, val in each.value.job_clusters :
    key => val if lookup(val, "new_cluster", null) != null }
    content {
      job_cluster_key = each.value.job_clusters[0].job_cluster_key
      new_cluster {
        spark_version       = lookup(each.value.job_clusters[0].new_cluster, "spark_version", null) != null ? each.value.job_clusters[0].new_cluster.spark_version : local.clusters.spark_version
        node_type_id        = lookup(each.value.job_clusters[0].new_cluster, "node_type_id", null) != null ? each.value.job_clusters[0].new_cluster.node_type_id : local.clusters.node_type_id
        driver_node_type_id = lookup(each.value.job_clusters[0].new_cluster, "driver_node_type_id", null) != null ? each.value.job_clusters[0].new_cluster.driver_node_type_id : local.clusters.driver_node_type_id
        policy_id           = lookup(each.value.job_clusters[0].new_cluster, "policy_id", null) != null ? each.value.job_clusters[0].new_cluster.policy_id : local.clusters.policy_id
        runtime_engine      = lookup(each.value.job_clusters[0].new_cluster, "runtime_engine", null) != null ? each.value.job_clusters[0].new_cluster.runtime_engine : local.clusters.runtime_engine
        spark_conf          = lookup(each.value.job_clusters[0].new_cluster, "spark_conf", null) != null ? each.value.job_clusters[0].new_cluster.spark_conf : null
        autoscale {
          min_workers = lookup(each.value.job_clusters[0].new_cluster.autoscale, "min_workers", null) != null ? each.value.job_clusters[0].new_cluster.autoscale.min_workers : local.clusters.autoscale.min_workers
          max_workers = lookup(each.value.job_clusters[0].new_cluster.autoscale, "max_workers", null) != null ? each.value.job_clusters[0].new_cluster.autoscale.max_workers : local.clusters.autoscale.max_workers
        }
        aws_attributes {
          first_on_demand        = lookup(each.value.job_clusters[0].new_cluster.aws_attributes, "first_on_demand", null) != null ? each.value.job_clusters[0].new_cluster.aws_attributes.first_on_demand : local.clusters.aws_attributes.first_on_demand
          availability           = lookup(each.value.job_clusters[0].new_cluster.aws_attributes, "availability", null) != null ? each.value.job_clusters[0].new_cluster.aws_attributes.availability : local.clusters.aws_attributes.availability
          instance_profile_arn   = lookup(each.value.job_clusters[0].new_cluster.aws_attributes, "instance_profile_arn", null) != null ? each.value.job_clusters[0].new_cluster.aws_attributes.instance_profile_arn : local.clusters.aws_attributes.instance_profile_arn
          zone_id                = lookup(each.value.job_clusters[0].new_cluster.aws_attributes, "zone_id", null) != null ? each.value.job_clusters[0].new_cluster.aws_attributes.zone_id : local.clusters.aws_attributes.zone_id
          spot_bid_price_percent = lookup(each.value.job_clusters[0].new_cluster.aws_attributes, "spot_bid_price_percent", null) != null ? each.value.job_clusters[0].new_cluster.aws_attributes.spot_bid_price_percent : local.clusters.aws_attributes.spot_bid_price_percent
        }
        custom_tags = each.value.job_clusters[0].new_cluster.custom_tags
      }
    }
  }

  dynamic "notification_settings" {
    for_each = { for key, val in each.value :
    key => val if key == "notification_settings" && val != null }
    content {
      no_alert_for_skipped_runs  = lookup(notification_settings.value, "no_alert_for_skipped_runs", null) != null ? notification_settings.value.no_alert_for_skipped_runs : null
      no_alert_for_canceled_runs = lookup(notification_settings.value, "no_alert_for_canceled_runs", null) != null ? notification_settings.value.no_alert_for_canceled_runs : null
    }
  }

  dynamic "schedule" {
    for_each = { for key, val in each.value :
    key => val if key == "schedule" && val != null }
    content {
      pause_status           = schedule.value.pause_status
      quartz_cron_expression = schedule.value.quartz_cron_expression
      timezone_id            = schedule.value.timezone_id
    }
  }

  dynamic "parameter" {
    for_each = contains(keys(each.value), "parameter") ? each.value["parameter"] : []
    content {
      default = parameter.value.default
      name    = parameter.value.name
    }
  }

  dynamic "queue" {
    for_each = { for key, val in each.value : key => val if key == "queue" && val != {} }
    content {
      # enabled = lookup(queue.value, "enabled", null) != null ? queue.value.enabled : false
      enabled = queue.value.enabled
    }
  }

  dynamic "task" {
    for_each = each.value.tasks
    content {
      task_key                  = task.value.task_key
      job_cluster_key           = lookup(task.value, "job_cluster_key", null) != null ? task.value.job_cluster_key : null
      existing_cluster_id       = lookup(task.value, "existing_cluster_id", null) != null ? task.value.existing_cluster_id : null
      max_retries               = contains(keys(task.value), "max_retries") ? task.value["max_retries"] : local.max_retries
      min_retry_interval_millis = contains(keys(task.value), "min_retry_interval_millis") ? task.value["min_retry_interval_millis"] : local.min_retry_interval_millis
      run_if                    = lookup(task.value, "run_if", null) != null ? task.value.run_if : null
      dynamic "notebook_task" {
        for_each = { for key, val in task.value :
        key => val if key == "notebook_task" }
        content {
          notebook_path   = notebook_task.value.notebook_path
          base_parameters = lookup(notebook_task.value, "base_parameters", null) != null ? notebook_task.value.base_parameters : {}
          source          = notebook_task.value.source
        }
      }

      dynamic "depends_on" {
        for_each = contains(keys(task.value), "depends_on") ? task.value["depends_on"] : []

        content {
          task_key = depends_on.value.task_key
          outcome  = lookup(depends_on.value, "outcome", null) != null ? depends_on.value.outcome : null
        }
      }

      dynamic "dbt_task" {
        for_each = { for key, val in task.value :
        key => val if key == "dbt_task" }
        content {
          project_directory = task.value.dbt_task.project_directory
          commands          = task.value.dbt_task.commands
          schema            = task.value.dbt_task.schema
          warehouse_id      = task.value.dbt_task.warehouse_id
          catalog           = task.value.dbt_task.catalog
        }
      }

      dynamic "spark_python_task" {
        for_each = { for key, val in task.value :
        key => val if key == "spark_python_task" }
        content {
          parameters  = task.value.spark_python_task.parameters
          python_file = task.value.spark_python_task.python_file
          source      = task.value.spark_python_task.source
        }
      }

      dynamic "condition_task" {
        for_each = { for key, val in task.value :
        key => val if key == "condition_task" }
        content {
          left  = task.value.condition_task.left
          op    = task.value.condition_task.op
          right = task.value.condition_task.right
        }
      }
      dynamic "library" {
        for_each = contains(keys(task.value), "libraries") ? task.value["libraries"] : []
        content {
          pypi { package = task.value.libraries[0].pypi.package }
        }
      }
      timeout_seconds = lookup(task.value, "timeout_seconds", null) != null ? task.value.timeout_seconds : null

      dynamic "email_notifications" {
        for_each = { for key, val in task.value :
        key => val if key == "email_notifications" && val != {} }
        content {
          on_success = lookup(email_notifications.value, "on_success", null) != null ? email_notifications.value.on_success : null
          on_start   = lookup(email_notifications.value, "on_start", null) != null ? email_notifications.value.on_start : null
          on_failure = lookup(email_notifications.value, "on_failure", null) != null ? email_notifications.value.on_failure : null
        }
      }

      dynamic "notification_settings" {
        for_each = { for key, val in task.value :
        key => val if key == "notification_settings" && val != {} }
        content {
          alert_on_last_attempt      = lookup(notification_settings.value, "alert_on_last_attempt", null) != null ? notification_settings.value.alert_on_last_attempt : false
          no_alert_for_canceled_runs = lookup(notification_settings.value, "no_alert_for_canceled_runs", null) != null ? notification_settings.value.no_alert_for_canceled_runs : false
          no_alert_for_skipped_runs  = lookup(notification_settings.value, "no_alert_for_skipped_runs", null) != null ? notification_settings.value.no_alert_for_skipped_runs : false
        }
      }
    }
  }
}

まとめ

モデルサービングのデプロイはモデルの再学習等を考慮して、Terraformで直接デプロイするのではなく、Notebook Jobを活用しています。Notebookはロジック部分を除き共通化できるので、テンプレート化して利用者に提供しています。利用者はテンプレートを活用することで、モデルを作成したのち高速にデプロイできる環境を利用できます。

Databricks × dbt 運用Tips:失敗したモデルだけを効率的にリトライする方法

本記事は、 Databricks - Qiita Advent Calendar 2024 - Qiitaシリーズ 3 の 24 日目の記事です。

データ基盤を運用する際、依存関係を考慮しながら、失敗したモデルやその依存関係のあるモデルのみを再実行したい場合があります。本記事では、Databricksとdbtを活用したリトライ方法についてご紹介します。

Case 1:失敗したモデルと依存関係のある後続モデルのリトライ

dbtの機能である「dbt retry」を活用し、失敗したモデルとその依存関係にある後続処理を効率的に再実行する方法をご紹介します。
dbtの実行結果は「run_results.json」で確認可能です。「dbt retry」では、この「run_results.json」に記録されたログを基に、失敗したモデルのみを再実行することができます。
dbt Cloudでは、失敗したモデルのみを再実行する機能が提供されています。一方、OSS版のdbt Coreを利用している場合、ログが欠損しないようストレージに永続化する仕組みが必要です。しかし、Databricksが提供する標準的なdbt処理ではこの要件に対応できないため、専用のdbt用共通モジュールを作成し、Notebook内でdbtを実行する運用を採用しています。

docs.getdbt.com

具体的には、以下のコードを使用して運用を行っています。Notebook Jobで渡されたパラメータに基づき、dbt runまたはdbt retryを制御します。また、永続化先のストレージで障害が発生した場合に実行ログを失う可能性を考慮し、ログは標準出力にも記録しています。

# Databricks notebook source

# COMMAND ----------
import os
import time
from datetime import datetime
import pytz
import json

env = dbutils.widgets.get("env")
dbt_tag = dbutils.widgets.get("dbt_tag")
threads = dbutils.widgets.get("threads")
dbt_dbfs_state_dir = dbutils.widgets.get("dbt_dbfs_state_dir")
dbt_tmp_dir = f'/tmp-{time.time()}'
dbt_project_dir = f'{dbt_tmp_dir}/dbt/config/{env}'
dbt_profile_dir = f'{dbt_tmp_dir}/dbt/config'
dbt_state_dir = f'{dbt_project_dir}/target'

os.environ['env'] = env
os.environ['dbt_tag'] = dbt_tag
os.environ['threads'] = threads
os.environ['DBT_TEMP_DIR'] = dbt_tmp_dir
os.environ['DBT_PROFILES_DIR'] = dbt_profile_dir
os.environ['DBT_PROJECT_DIR'] = dbt_project_dir
os.environ['DBT_STATE_DIR'] = dbt_state_dir
os.environ['DBT_ENV_SECRET_TOKEN'] = dbutils.secrets.get(f'<secret>', f'<secret-token>')

# COMMAND ----------
# MAGIC %sh
# MAGIC set -eu
# MAGIC ls -ltr ../
# MAGIC mkdir -p ${DBT_STATE_DIR}
# MAGIC cp -r ../dbt ${DBT_TEMP_DIR}
    
# COMMAND ----------
# This params are used to run dbt command with the custome date

dbt_retry = dbutils.widgets.get('dbt_retry')
dbt_command = f"dbt run   --select {dbt_tag} --target={env} --threads {threads}"
if dbt_retry != 'false':
    dbutils.fs.cp(f'{dbt_dbfs_state_dir}/run_results.json', f'file:{dbt_state_dir}/run_results.json')
    dbt_command = f"dbt retry --target={env} --threads {threads}"
print(f'dbt command: {dbt_command}')
os.environ['DBT_COMMAND'] = dbt_command

# COMMAND ----------
# MAGIC %sh
# MAGIC set -eu
# MAGIC cd ${DBT_TEMP_DIR}
# MAGIC dbt deps
# MAGIC ${DBT_COMMAND}

# COMMAND ----------

file_path = f'{dbt_state_dir}/run_results.json'
try:
    with open(file_path, 'r') as file:
        data = json.load(file)
        print(json.dumps(data))
    if dbt_retry != 'false':
        # Remove crc file to avoid error when copying to dbfs
        dbutils.fs.rm( f'file:{dbt_state_dir}/.run_results.json.crc')
    dbutils.fs.cp(f'file:{file_path}', f'{dbt_dbfs_state_dir}/run_results.json')
    for result in data['results']:
        if result['status'] == 'error':
            raise ValueError("Error detected in results")
except Exception as e:
    raise e

Case 2:データ不整合が判明したモデルのリトライ

次に想定されるシナリオとして、モデルの実行結果に不整合が発生し、再度成功したモデルを実行したい場合があります。このようなユースケースでは、dbtのタグ機能を活用します。以下に、モデルmodel_aおよびmodel_bを実行するケースを示します。

dbt run --select tag:model_a,model_b

```

まとめ

dbtで失敗したモデルを再実行する際には、dbt retryを利用しています。ログの損失を防ぐために、ログを永続化する仕組みを整えた上で、Notebookを通じてdbtを実行しています。また、既に成功しているモデルをリトライしたい場合には、dbtのタグ機能を活用して対応しています。

DatabricksのOSS(terraform, dbt)にコントリビュートした話

この記事は、Databricksとdbtのアドベントカレンダー2023の13日めの記事です。

qiita.com

qiita.com

ここ半年ほどDatabricksを使い始めて、最近terraformやdbtにコントリビュートする機会があったので、その紹介をします。色々見つかって楽しいので来年はもっとコントリビュートしていきたいです。

Open Source Repositories Title & Url Status
terraform-provider-databricks Sort based on the Task Key specified in the 'Depends On' field https://github.com/databricks/terraform-provider-databricks/pull/3000 merged
terraform-provider-databricks Add Test Code for Sorting Tasks in the 'Depends On' Field of a Job https://github.com/databricks/terraform-provider-databricks/pull/3183 merged
terraform-provider-databricks Added an item to check if the ticket opener wants to do a bug fix https://github.com/databricks/terraform-provider-databricks/pull/3020 merged
dbt-databricks Fix dbt incremental_strategy behavior by fixing schema table existing check https://github.com/databricks/dbt-databricks/pull/530 merged
dbt-databricks Add DBT_DATABRICKS_UC_INITIAL_CATALOG Option https://github.com/databricks/dbt-databricks/pull/537 merged
dbt-databricks Eliminate the conversion of the schema to lowercase for schema-related test https://github.com/databricks/dbt-databricks/pull/541 merged
dbt-databricks Add schema option for testing https://github.com/databricks/dbt-databricks/pull/548 / https://github.com/databricks/dbt-databricks/pull/538 merged

Databricksはまだ、発展途上の部分もありOSSに対してコントリビュートできる機会が多く楽しめています。来年は開発や運用で見つけたバグだけではなく、issueも積極的に漁りながら貢献していきたいです。

Javaのバージョン管理 備忘録

MacJavaのバージョン管理をする際に備忘録

jenv install

brew install jenv
echo "if which jenv > /dev/null; then eval "$(jenv init -)"; fi" >> ~/.zshrc 
source ~/.zshrc 

M1 Macの場合

sudo softwareupdate --install-rosetta # M1で入れるのに必要

JDK Install

brew tap AdoptOpenJDK/openjdk
brew install --cask adoptopenjdk8 
brew install --cask adoptopenjdk

/usr/libexec/java_home -v 1.8
/Library/Java/JavaVirtualMachines/adoptopenjdk-8.jdk/Contents/Home
/usr/libexec/java_home -v 16         
/Library/Java/JavaVirtualMachines/adoptopenjdk-16.jdk/Contents/Home

JenvにJDKを追加

jenv add /Library/Java/JavaVirtualMachines/adoptopenjdk-8.jdk/Contents/Home
jenv add /Library/Java/JavaVirtualMachines/adoptopenjdk-16.jdk/Contents/Home
```


Jenvに追加されたバージョンを確認

jenv versions
* system (set by /Users/keisuke.taniguchi/.jenv/version)
  1.8
  1.8.0.292
  16.0
  16.0.1
  openjdk64-1.8.0.292
  openjdk64-16.0.1


利用するバージョンを選択

jenv global 1.8

java -version
openjdk version "1.8.0_292"
OpenJDK Runtime Environment (AdoptOpenJDK)(build 1.8.0_292-b10)
OpenJDK 64-Bit Server VM (AdoptOpenJDK)(build 25.292-b10, mixed mode)

ref
Macで多様なJavaバージョンとディストリビューションを管理:HomebrewとjEnvの活用 #homebrew - Qiita

Java 11

brew install --cask temurin11
jenv add /Library/Java/JavaVirtualMachines/temurin-11.jdk/Contents/Home

Java21
```
brew install --cask temurin21
jenv add /Library/Java/JavaVirtualMachines/temurin-21.jdk/Contents/Home

jenv global 21.0
keisuke.taniguchi@keisuketaniguchinoMacBook-Pro api % jenv versions
system
1.8
1.8.0.292
11.0
11.0.26

21.0 (set by /Users/keisuke.taniguchi/.jenv/version)

21.0.6
openjdk64-1.8.0.292
temurin64-11.0.26
temurin64-21.0.6
```

BigQueryに書き込まれたSQL Serverの変更ログを用いて、変更のあったPKの最新データと変更前のデータを取得する方法

ZOZO Advent Calendar 2022 カレンダー25日目の記事です
qiita.com

BigQueryに書き込まれたSQL Serverの変更追跡ログを使って、変更のあったPKの変更ログと変更前のログを取得する方法をご紹介します。

ZOZOではSQL Serverの変更追跡機能を使い、変更のあったPKの最新のレコードをBigQueryに連携しています。SQL Serverの変更追跡では以下のようなクエリを実行して、変更のあったPKとPKに紐づく最新のデータを取得できます。

  SELECT
    a.SYS_CHANGE_OPERATION as changetrack_type,
    a.SYS_CHANGE_VERSION as changetrack_ver,
    #{columns}
  FROM
    CHANGETABLE(CHANGES #{@tablename},
      @前回更新したバージョン) AS a
  LEFT OUTER JOIN #{@tablename} ON a.#{@primary_key} = b.#{@primary_key}

SQL ServerなどDBの変更を追跡する機能はCDC(Change Data Capture)と呼ばれており、DBで変更のあったデータを全て連携するログベースのCDCから、クエリで変更データをポーリング するCDCなどがあります。SQL Serverの変更追跡は後者に該当します。

ログベースのCDCであれば変更前データも取れますが、SQL Serverの変更追跡機能を使う場合、変更のあったPKの変更前のデータは取得できません。本記事ではSQL Serverの変更追跡機能を使ってBigQueryへ連携した変更のあったPKの変更ログに加えて、変更前ログを取得する方法をご紹介します。

techblog.zozo.com
datacater.io

BigQueryで実現する方法

BigQuery上で変更のあったPKの変更ログと変更前のログを取得する方法を紹介します。

変更のあった差分データを取得

SQL Serverの変更追跡機能で連携された差分テーブルから直近2日分の変更データを取得します。直近2日にしているのは、BigQueryパーティションでコストとパフォーマンスを向上させるのと、後ほど紹介する変更前のデータが変更のあった差分データに含まれていない場合に対応するためです。

  streaming AS (
  SELECT
    changetrack_type,
    changetrack_ver ,
    bigquery_insert_time,
   <primary_key> AS primary_key,
    <columns>
  FROM
    <変更追跡で連携された差分テーブル>
  WHERE
    bigquery_insert_time >= TIMESTAMP_SUB(CAST(FORMAT_TIMESTAMP("%Y-%m-%d", TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 2 day), "Asia/Tokyo") AS timestamp), INTERVAL 9 HOUR)),

各カラムは以下の情報を含んでいます。

  • changetrack_type: 変更処理。どのようは変更があったか確認できます(I: insert, U: update, D: delete)
  • changetrack_ver:変更追跡のバージョン。SQL Serverトランザクションごとに発行されるバージョンです。数値の大きいデータが最新になります
  • primary_key: SQL ServerのPKをセットしています
  • bigquery_insert_time: BigQueryに書き込まれた時刻.BigQueryのパーティション機能や遅延計測に使っています
  • columns: 変更を追跡しているテーブルのカラム

最新の変更追跡バージョンを集計

先ほど抽出したデータを用いて、PKごとに最新の変更追跡バージョンを集計します。

  streaming_latest_version AS (
  SELECT
    primary_key,
    MAX(changetrack_ver) AS changetrack_ver_max
  FROM
    streaming
    -- set instead of tracking version
  WHERE
    bigquery_insert_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 60 second)
  GROUP BY
    primary_key ),

最新の変更追跡バージョンを用いて最新の変更ログを取得

PKごとに集計した最新の変更追跡バージョンと変更ログをJOINすることで、変更ログから変更のあったPKの最新のデータを取得できます。

  streaming_latest AS (
  SELECT
    changetrack_type,
    changetrack_ver,
    streaming.primary_key,
    <columns>
  FROM
    streaming
  INNER JOIN
    streaming_latest_version
  ON
    streaming.primary_key = streaming_latest_version.primary_key
    AND streaming.changetrack_ver = streaming_latest_version.changetrack_ver_max ),

変更前の変更追跡バージョンを取得

次に変更のあったPKの変更前のデータを取得します。変更のあった差分データと最新のバージョンをLEFT JOINし、突合できなかったデータ(changetrack_ver_max IS NULL)から変更前の変更追跡バージョンを取得します。変更のあった最新のバージョンは除外されるため変更前の変更追跡バージョンを取得できます。

  streaming_before_latest_version AS (
  SELECT
    primary_key,
    MAX(changetrack_ver) AS changetrack_ver_before_latest
  FROM (
    SELECT
      streaming.primary_key,
      streaming.changetrack_ver,
      streaming_latest_version.changetrack_ver_max
    FROM
      streaming
    LEFT OUTER JOIN
      streaming_latest_version
    ON
      streaming.primary_key = streaming_latest_version.primary_key
      AND streaming.changetrack_ver = streaming_latest_version.changetrack_ver_max)
  WHERE
    changetrack_ver_max IS NULL
  GROUP BY
    primary_key ),

変更前の変更追跡バージョンを用いて変更前のログを取得

変更前の変更追跡バージョンを使って、変更のあったPKの変更前のデータを取得できます。

  streaming_before_latest AS (
  SELECT
    changetrack_type,
    changetrack_ver,
    streaming.primary_key,
  FROM
    streaming
  INNER JOIN
    streaming_before_latest_version
  ON
    streaming.primary_key = streaming_before_latest_version.changetrack_ver_before_latest
    AND streaming.changetrack_ver = streaming_before_latest_version.changetrack_ver_before_latest ),

前日分の全量テーブルから、差分データに含まれていない変更前データを取得

BigQueryのパーティションで絞りこんでいるため、変更のあった差分データの中に変更前のデータが含まれているとは限りません。変更のあった差分データの中に変更前のデータが含まれていない場合は前日分の全量テーブルから変更前のデータを取得します。

  daily_before_latest AS (
  SELECT
    CAST(NULL AS string) AS changetrack_type,
    CAST(NULL AS int64) AS changetrack_ver,
 <columns>
  FROM (
    SELECT
     <primary_key> AS primary_key,
      <columns>
    FROM
      <前日の全量日付サフィックステーブル>
    WHERE
      _TABLE_SUFFIX IN (SUBSTR(FORMAT_TIMESTAMP("%Y%m%d", TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 1 day), "Asia/Tokyo"), 3)))
  WHERE
    primary_key NOT IN (
    SELECT
      primary_key
    FROM
      streaming_before_latest_version)
    AND primary_key IN (
    SELECT
      primary_key
    FROM
      streaming_latest_version) )

変更のあったPKの変更ログと変更前のログを取得

最後に変更データと変更前データをUNIONすることで、変更のあったPKの最新データと変更前のデータを取得できます。

SELECT
  *
FROM
  streaming_latest
UNION ALL
SELECT
  *
FROM
  streaming_before_latest
UNION ALL
SELECT
  *
FROM
  daily_before_latest

BigQueryクエリ完成形

完成形のクエリは以下のようになります。

WITH
  # 本日分の差分テーブル
  streaming AS (
  SELECT
    changetrack_type,
    changetrack_ver,
    bigquery_insert_time,
    <primary_key> AS primary_key,
    <columns>
  FROM
    <変更追跡で連携された差分テーブル>
  WHERE
    bigquery_insert_time >= TIMESTAMP_SUB(CAST(FORMAT_TIMESTAMP("%Y-%m-%d", TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 2 day), "Asia/Tokyo") AS timestamp), INTERVAL 9 HOUR)),
  # 差分テーブルから最新のバージョン
  streaming_latest_version AS (
  SELECT
    primary_key,
    MAX(changetrack_ver) AS changetrack_ver_max
  FROM
    streaming
    -- set instead of tracking version
  WHERE
    bigquery_insert_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 60 second)
  GROUP BY
    primary_key ),
  # 差分テーブルから最新のログ
  streaming_latest AS (
  SELECT
    changetrack_type,
    changetrack_ver,
    streaming.primary_key,
    <columns>
  FROM
    streaming
  INNER JOIN
    streaming_latest_version
  ON
    streaming.primary_key = streaming_latest_version.primary_key
    AND streaming.changetrack_ver = streaming_latest_version.changetrack_ver_max ),
  # 差分テーブルにある変更前のバージョン(最新の一つ前のバージョン)
  streaming_before_latest_version AS (
  SELECT
    primary_key,
    MAX(changetrack_ver) AS changetrack_ver_before_latest
  FROM (
    SELECT
      streaming.primary_key,
      streaming.changetrack_ver,
      streaming_latest_version.changetrack_ver_max
    FROM
      streaming
    LEFT OUTER JOIN
      streaming_latest_version
    ON
      streaming.primary_key = streaming_latest_version.primary_key
      AND streaming.changetrack_ver = streaming_latest_version.changetrack_ver_max)
  WHERE
    changetrack_ver_max IS NULL
  GROUP BY
    primary_key ),
  # 差分テーブルにある変更前のデータ(最新の一つ前のデータ)
  streaming_before_latest AS (
  SELECT
    changetrack_type,
    changetrack_ver,
    streaming.primary_key,
    <columns>
  FROM
    streaming
  INNER JOIN
    streaming_before_latest_version
  ON
    streaming.primary_key = streaming_before_latest_version.changetrack_ver_before_latest
    AND streaming.changetrack_ver = streaming_before_latest_version.changetrack_ver_before_latest ),
  # 差分テーブルにない変更前のデータ
  daily_before_latest AS (
  SELECT
    CAST(NULL AS string) AS changetrack_type,
    CAST(NULL AS int64) AS changetrack_ver,
    primary_key,
    <columns>
  FROM (
    SELECT
     <primary_key> AS primary_key,
     <columns>
    FROM
       <前日の全量日付サフィックステーブル>
    WHERE
      _TABLE_SUFFIX IN (SUBSTR(FORMAT_TIMESTAMP("%Y%m%d", TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 1 day), "Asia/Tokyo"), 3)))
  WHERE
    primary_key NOT IN (
    SELECT
      primary_key
    FROM
      streaming_before_latest_version)
    AND primary_key IN (
    SELECT
      primary_key
    FROM
      streaming_latest_version) )
SELECT
  *
FROM
  streaming_latest
UNION ALL
SELECT
  *
FROM
  streaming_before_latest
UNION ALL
SELECT
  *
FROM
  daily_before_latest

Dataflow JDBC テンプレート検証(Java)

Python版を調べてみたがクエリの上書きができなかったり、余計な通信が発生していたりと現時点で本番運用できる状態ではなかった。Java版が使えるか検証してみる。Java版はテンプレートが用意されていたので、PostgresとSQL Serverでそれぞれ検証してみた。結論としてはテンプレートをそのまま使うことはできないけど、クエリの上書きや無駄に実行環境からコネクション張る等Apache Beam Pythonで問題だった動きはしませんでした。本番運用でも使えそうです。
www.case-k.jp
www.case-k.jp
www.case-k.jp


テンプレートでサポートしてるのはBigQueryへの追加のみで全量置換はできていない。全量置換する場合自前で作る必要がある。パフォーマンス等調査は必要だがクエリの上書きやSQL Serverもサポートしていたので自前で用意すれば実務には使えそう。

        .apply(
            "Write to BigQuery",
            BigQueryIO.writeTableRows()
                .withoutValidation()
                .withCreateDisposition(BigQueryIO.Write.CreateDisposition.CREATE_NEVER)
                .withWriteDisposition(BigQueryIO.Write.WriteDisposition.WRITE_APPEND)
                .withCustomGcsTempLocation(options.getBigQueryLoadingTemporaryDirectory())
                .to(options.getOutputTable()));

github.com

並列実行する場合JOBの同時実行数が懸念。プロジェクトごとに25。上限をあげてもオーガナイゼーションレベルで125が最大となっている。相談すれば増やせはするかも。
増やせないなら数百テーブルを高速かつ、並列実行でやる場合GKEのAutopilotで対応した方がコスト、パフォーマンス面で良さそうに思える。

- Each Google Cloud project can run at most 25 concurrent Dataflow jobs.
- If you opt-in to organization level quotas, each organization can run at most 125 concurrent Dataflow jobs. 

Note: If you would like to run more than 25 concurrent Dataflow jobs for your project or more than 125 concurrent Dataflow jobs for your organization, contact Google Cloud Support and we will increase the limit to a value that better suits your needs.
cloud.google.com


設定のパラメータ

  • Postgres
# required parameters
template: Jdbc to BigQuery
jdbc connection URL string:jdbc:postgresql://<private ip address>:5432/beam
jdbc driver class name:org.postgresql.Driver
jdbc source sql query:<query>
BigQuery output table : <project>:<dataset>.<table>
gcs paths for Jdbc drivers: gs://<gcs-bucket>/postgresql-42.2.18.jre7.jar
Temporary directory for BigQuery loading process:gs: gs://<gcs-bucket>//tmp/


# option parameters
Jdbc connection username: <user-name>
Jdbc connection password: <password>
subnetwork:  <subnetwork>
# required parameters
jdbc connection URL string:jdbc:sqlserver://<private-ip>:1433;database=beam;
jdbc driver class name:com.microsoft.sqlserver.jdbc.SQLServerDriver
jdbc source sql query:<query>
BigQuery output table : <project>:<dataset>.<table>
gcs paths for Jdbc drivers: gs://<gcs-bucket>/mssql-jdbc-8.4.1.jre8.jar
Temporary directory for BigQuery loading process:gs: gs://<gcs-bucket>//tmp/


# option parameters
Jdbc connection username: <user-name>
Jdbc connection password: <password>
subnetwork:  <subnetwork>