PySpark によるメタデータデプロイの実践

## 概要

PySpark によるメタデータデプロイの実践について説明する。本記事では、Databricks での実行結果を掲載。

本記事のコードを含むノートブックを以下のリンクに保存してあり、Databricks 等の環境にインポートして実行が可能。

https://github.com/manabian-/databricks_development_practices/blob/main/docs/jp/notebooks/T50__L100__010_PySpark%20%E3%81%AB%E3%82%88%E3%82%8B%E3%83%A1%E3%82%BF%E3%83%87%E3%83%BC%E3%82%BF%E3%83%87%E3%83%97%E3%83%AD%E3%82%A4%E3%81%AE%E5%AE%9F%E8%B7%B5.ipynb

## 本記事の位置付け

次の開発ガイドシリーズにおけるメタデータデプロイ分野の1記事であり、リンク先には記事にて記事の全体像を整理している。

| GroupID | 分野               |
| ------- | ---------------------- |
| T10     | Spark概要                  |
| T20     | データエンジニアリング |
| T30     | データ品質チェック     |
| T40     | データサイエンス       |                                         |
| **T50**     | **メタデータデプロイ**     |
| T60     | テスト                 |
| T70     | DevOps                 |

https://qiita.com/manabian/items/67bcb6c5823bc4d5d944

## データデプロイ概要

Spark におけるメタデータを保持する主なオブジェクトとして次のものがあり、CRUD（作成、読込、更新、削除）という観点で適切なメタデータデプロイが必要。Spark （+Delta Lake）を利用するメリットの１つにデータ参照のダウンタイムがほぼないことがあり、メタデータデプロイ時にもダウンタイムを最小限とすることが望ましい。

| #    | 取得元                    | 取得元の詳細                                                           |
| ---- | ------------------------- | ------------------------------------------------------------ |
| 1    | Delta Lake ディレクトリ | - _delta_log                                                 |
| 2    | メタストア                | - Spark Schema(Database)<br/>  - Spark Table<br/>  - Spark View |
| 3    | Spark Dataframe           | - SparkSession<br/>  - Data Sources                                                             |


Spark においては、メタストアにて多くのメタデータが格納される。一般的には Hive メタストアを用いることが多いが、Spark プロバイダー固有のメタストア（Databricks における Unity Catalog）が提供されていることがある。メタストアからメタデータを取得する際には、Spark 環境から`DESCRIBE`などのコマンドにより参照する。Delta Lake を用いる場合にはメタストアに存在しないメタデータが Delta Lake ディレクトリに格納されることもある。


メタデータのデプロイ方法としては命令型と宣言型があるが、Spark にて宣言型でデプロイを行うためのツールは公開されていないため、宣言型デプロイを行うためにはスクラッチでの開発が必要。

| #    | デプロイ方法 | 実施方法                                                     | 他データストアのツール例                                                     |
| ---- | ------------ | ------------------------------------------------------------ | ------------------------------------------------------------ |
| 1    | 命令型       | デプロイ対象の DDL 文等の処理を随時実行する。                |                                                              |
| 2    | 宣言型       | デプロイ後のテーブル定義等を保持したをモデルの定義を行い、そのモデルとの差分を DDL 文等により反映する。 | - [Flyway](https://flywaydb.org/) <br />- [データ層アプリケーション (DAC) - SQL Server ](https://docs.microsoft.com/ja-jp/sql/relational-databases/data-tier-applications/data-tier-applications?view=sql-server-ver16) |


メタデータの更新を行う際には、ALTER 文の実行のみでは完結しないものがあることに注意が必要。

- ALTER 文を実行のみで可能
    1. テーブル名を変更
    2. カラムの追加
    3. カラムのコメントを変更
    4. カラム順の変更
    5. テーブルプロパティ（TBLPROPERTIES）の変更
    6. Not NULL制約の設定と削除
    7. Check制約の設定と削除
- データの再書き込みが必要
    1. カラム名を変更
    2. カラムを削除
    3. カラムのデータ型を変更
    4. パーティションカラムを変更
    5. 生成列の追加
- 他のパスへのデータの書き込みが必要
    1. テーブルの保存場所（LOCATION）の変更
- Optimizeの実行が必要
    1. bloomfitlerの設定


## 事前準備

```python
from pyspark.sql import Row
from decimal import *
import datetime
import pprint
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24031/d5c5eab5-56d7-8411-2cd4-9d00aa129364.png)


```python
import random, string
random_string = ''.join(random.choices(string.ascii_letters, k=5))
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24031/01d6415b-6c53-e593-b7f2-0bedfacfbc97.png)


```python
db_name = f'_metadata_test_database_{random_string}'
print(db_name)
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24031/8dab5271-10ec-a2e5-e3b8-1df701715f4d.png)

```python
# データベースを作成
sql = f'DROP DATABASE IF EXISTS {db_name} CASCADE'
spark.sql(sql)

sql = f'CREATE DATABASE {db_name}'
spark.sql(sql)
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24031/e38e60ae-c470-7d0c-6706-a8381fb4b9a8.png)

```python
table_name      = '_metadata_test_table'
table_full_name = f'{db_name}.{table_name}'

sample_Data = [
    Row(
        string_column    = 'AAA', 
        byte_column      = 1, 
        integer_column   = 1, 
        bigint_column    = 1, 
        float_column     = 12.300000190734863, 
        double_column    = 12.3, 
        numeric_column   = Decimal('12'), 
        boolean_column   = True, 
        date_column      = datetime.date(2020, 1, 1), 
        timestamp_column = datetime.datetime(2021, 1, 1, 0, 0), 
        binary_column    = bytearray(b'A'), 
        struct_column    = Row(
            struct_string_column = 'AAA', 
            struct_int_column    = 1
        ), 
        array_column     = ['AAA', 'BBB', 'CCC'], 
        map_column       = {'AAA': 1}
    )
]


schema = '''
--文字型
string_column string,

--整数型
byte_column byte,
integer_column integer,
bigint_column bigint,

--浮動小数点型
float_column float,
double_column double,
numeric_column numeric,

--真偽型
boolean_column boolean,

--日付時刻
date_column date, 
timestamp_column timestamp,

--バイナリー型
binary_column binary,

--複合型
struct_column struct<
    struct_string_column :string,
    struct_int_column    :int
>,
array_column array<string>, 
map_column map<string, int>
'''

df = spark.createDataFrame(sample_Data, schema)
df.display()

# テーブルを作成
spark.sql(f'DROP TABLE IF EXISTS {table_full_name}')
df.write.format('delta').saveAsTable(table_full_name)
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24031/240688d5-cf94-4032-863a-dd66e007f795.png)


```python
table_name_02      = '_metadata_test_table_02'
table_full_name_02 = f'{db_name}.{table_name_02}'

sql = f'''
DROP TABLE IF EXISTS {table_full_name_02}
'''
spark.sql(sql)

sql = f'''
CREATE TABLE {table_full_name_02}
(
    id BIGINT GENERATED ALWAYS AS IDENTITY
    ,string_column string
    ,integer_column integer
    ,date_column date
    ,generated_col INT GENERATED ALWAYS AS (integer_column)
)
USING delta
TBLPROPERTIES (
    delta.autoOptimize.optimizeWrite = True, 
    delta.autoOptimize.autoCompact   = True,
    delta.dataSkippingNumIndexedCols = 1
  )
PARTITIONED BY (
    string_column
)
COMMENT "table_comment"
'''
spark.sql(sql)

```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24031/1d5d4e91-72c6-1a94-81ea-086af0c566e4.png)

```python
table_name_03      = '_metadata_test_table_03'
table_full_name_03 = f'{db_name}.{table_name_03}'

sql = f'''
DROP TABLE IF EXISTS {table_full_name_03}
'''
spark.sql(sql)

sql = f'''
CREATE OR REPLACE TABLE {table_full_name_03}
(
    col_001 string
    ,col_002 string
    ,col_003 string
)
USING delta
TBLPROPERTIES (
    delta.autoOptimize.optimizeWrite = True, 
    delta.autoOptimize.autoCompact   = True,
    delta.dataSkippingNumIndexedCols = 1
  )
'''
spark.sql(sql)

```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24031/f4dbf08e-5faa-a063-6b81-31ee97872e88.png)

```python
view_name      = '_v_metadata_test_table'
view_full_name = f'{db_name}.{view_name}'

sql = f'''
CREATE OR REPLACE VIEW {view_full_name}
AS
SELECT
  *
  FROM
    {table_full_name}
'''

spark.sql(sql)
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24031/ee0ef4de-9c29-43ee-c875-c6a2a4784ffe.png)

## Delta Lake ディレクトリにおけるメタデータ

Delta Lake ディレクトリでは、直下に作成される`_delta_log` ディレクトリに情報が格納される。保持する情報については、Delta Lake の Gtihub レポジトリにて詳細が記載されている。

- [Understanding the Delta Lake Transaction Log - Databricks Blog](https://www.databricks.com/blog/2019/08/21/diving-into-delta-lake-unpacking-the-transaction-log.html)
- [delta/PROTOCOL.md at master · delta-io/delta · GitHub](https://github.com/delta-io/delta/blob/master/PROTOCOL.md)

この章では、`_delta_log`にどのような情報が格納されているかをコードの実行を通して説明する。

```python
# DESCRIBE の実行結果は、メタストアから取得
sql = f'DESCRIBE {table_full_name_02}'

spark.sql(sql).display()
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24031/100430c5-af43-b7ce-a2f5-c2818339cb55.png)

```python
# DESCRIBE HISTORY の実行結果は、Delta lake テーブルのメタデータの一部を確認可能
sql = f'DESCRIBE HISTORY {table_full_name_02}'

# `userName` カラムにメールアドレスが含まれる
# spark.sql(sql).display()
spark.sql(sql).drop('userName').display()
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24031/864dd900-492a-069e-c832-e474c9a84fe9.png)


```python
file_path = spark.sql(f'DESC EXTENDED {table_full_name_02}').where("col_name = 'Location'").select('data_type').collect()[0][0]
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24031/01f5f54d-4b00-c85a-5873-b7b1b3f2c158.png)

```python
display(dbutils.fs.ls(file_path))
display(dbutils.fs.ls(f'{file_path}/_delta_log'))
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24031/6a9d5869-3493-42f7-ae5e-8d63eb12d4c3.png)

```python
print(dbutils.fs.head(f'{file_path}/_delta_log/00000000000000000000.crc'))
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24031/a76bcd55-d18c-92f4-4942-bd2e4cc61c0b.png)


```python
# 下記については、ユーザー名（メールアドレス）が表示されるため実行結果共有時に注意が必要
print(dbutils.fs.head(f'{file_path}/_delta_log/00000000000000000000.json'))
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24031/425b343e-c179-9b76-9429-c1e62f93a11d.png)

```python
file_contents = dbutils.fs.head(f'{file_path}/_delta_log/00000000000000000000.json')
file_contents = file_contents.split('\n')

import json
print('--protocol--')
print(json.loads(file_contents[0]))
print('--metaData--')
print(json.loads(file_contents[1]))

# 下記については、ユーザー名（メールアドレス）が表示されるため実行結果共有時に注意が必要
print('--Operation--')
print(json.loads(file_contents[2]))
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24031/a98f2eff-ea58-46bc-06b9-d838caf1821b.png)

```python
sql = f'''
CREATE BLOOMFILTER INDEX
ON {table_full_name_02}
FOR COLUMNS(
  integer_column
)
'''
spark.sql(sql)
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24031/d9313eb1-7845-b257-8428-831378d153dd.png)

```python
display(dbutils.fs.ls(file_path))
display(dbutils.fs.ls(f'{file_path}/_delta_log'))
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24031/f129cc4a-2696-e9f6-c0fe-23afcfc5f63b.png)

```python
print(dbutils.fs.head(f'{file_path}/_delta_log/00000000000000000000.crc'))
print(dbutils.fs.head(f'{file_path}/_delta_log/00000000000000000001.crc'))
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24031/62863788-ff98-32a2-b62c-3b05afaa3ce1.png)

```python
# 下記については、ユーザー名（メールアドレス）が表示されるため実行結果共有時に注意が必要
print(dbutils.fs.head(f'{file_path}/_delta_log/00000000000000000001.json'))
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24031/826970d3-d1c7-16b7-c70a-047c1e2259fe.png)

```python
file_contents = dbutils.fs.head(f'{file_path}/_delta_log/00000000000000000001.json')
file_contents = file_contents.split('\n')

import json
print('--metaData--')
print(json.loads(file_contents[0]))

# 下記については、ユーザー名（メールアドレス）が表示されるため実行結果共有時に注意が必要
print('--Operation--')
print(json.loads(file_contents[1]))
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24031/1f929b31-bc55-452c-7a69-282e1ec9d2d4.png)

```python
sql = f'''
INSERT INTO {table_full_name_02}
(
    string_column
    ,integer_column
    ,date_column
)
VALUES
('a', 1, '2020-01-01'),
('b', 2, '2020-02-01'),
('c', 3, '2020-03-01')

'''

spark.sql(sql)
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24031/7396767f-5138-ebe7-10b2-d9b689a8b776.png)

```python
display(dbutils.fs.ls(file_path))
display(dbutils.fs.ls(f'{file_path}/_delta_log'))
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24031/e6758b17-215e-0963-e777-2978b9999551.png)

```python
print(dbutils.fs.head(f'{file_path}/_delta_log/00000000000000000001.crc'))
print(dbutils.fs.head(f'{file_path}/_delta_log/00000000000000000002.crc'))
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24031/a9b260c3-ab14-d8f1-b2d3-c60f9bc38d24.png)

```python
# 下記については、ユーザー名（メールアドレス）が表示されるため実行結果共有時に注意が必要
print(dbutils.fs.head(f'{file_path}/_delta_log/00000000000000000002.json'))
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24031/1956aa9f-b21e-c6a3-cee9-4b18c82587ec.png)

```python
file_contents = dbutils.fs.head(f'{file_path}/_delta_log/00000000000000000002.json')
file_contents = file_contents.split('\n')

import json
print('--Operation--')
print(json.loads(file_contents[0]))
print('--Operation--')
print(json.loads(file_contents[1]))
print('--Operation--')
print(json.loads(file_contents[2]))

# 下記については、ユーザー名（メールアドレス）が表示されるため実行結果共有時に注意が必要
print('--commitInfo--')
print(json.loads(file_contents[3]))
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24031/b52f4e09-c48f-c647-7060-6e7adea08777.png)


## メタデータのデプロイ

### メタデータの命令型デプロイ

```python
spark.table(table_full_name_03).printSchema()
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24031/b3b0dcd7-b688-0988-5135-d302e1ddc804.png)


```python
sql = f'''
ALTER TABLE {table_full_name_03}
  ADD COLUMN 
  (
    col_004 string
  )
'''

spark.sql(sql)
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24031/e9765215-37ab-f094-efa0-0a302e866e8f.png)

```python
spark.table(table_full_name_03).printSchema()
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24031/0ead2c6c-f52b-4935-67e4-605e97878bee.png)

### メタデータの宣言型デプロイ

```python
# 現在のテーブルカラムと設定値を比較して差分がある場合にカラムを追加するメソッドを追加
import inspect

def add_cols_to_spark_tbl(
    cols_conf,
    tbl_name,
):
    """Spark テーブルに対してカラムを追加
    """
    # 期待値のカラムリストを取得
    expected_cols_list = [d["name"] for d in cols_conf]

    # 実際のカラムリストを取得
    actual_cols_list = spark.table(tbl_name).columns

    # 設定値のカラムと実際のカラムを比較して、追加対象のカラム一覧を取得
    cols_to_be_add = list(set(sorted(expected_cols_list)) - set(sorted(actual_cols_list)))

    # 現在のテーブルカラムと設定値を比較して差分がある場合にカラムを追加
    for col_to_be_add in cols_to_be_add:
        for col_info_to_be_executed in [col_info for col_info in cols_conf if col_info['name'] == col_to_be_add]:
            col_name = col_info_to_be_executed['name']
            data_type = col_info_to_be_executed['type']
            add_col_ddl = f'''
            ALTER TABLE {tbl_name}
              ADD COLUMNS {col_name} {data_type}'''
            add_col_ddl = inspect.cleandoc(add_col_ddl)
            spark.sql(add_col_ddl)

            # 実行した DDL を表示
            print(add_col_ddl)
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24031/1765af3e-79a4-4942-2675-ca72fba3c021.png)

```python
# 現在のテーブルと同義の設定値を定義
tbl_conf = {
    'table_name': table_full_name_03,
    'schema': [
        {'name': 'col_001', 'type': 'STRING'},
        {'name': 'col_002', 'type': 'STRING'},
        {'name': 'col_003', 'type': 'string'},
        {'name': 'col_004', 'type': 'STRING'},
    ],
}
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24031/780694d2-4e2a-4f2e-e4b4-c85f77ac464c.png)

```python
# カラム追加の処理が実行されない
add_cols_to_spark_tbl(
    cols_conf = tbl_conf['schema'],
    tbl_name = tbl_conf['table_name'],
)
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24031/26845368-cbf9-5621-9640-116e00d7ddd9.png)

```python
# `col_005`を追加した設定値を定義
tbl_conf = {
    'table_name': table_full_name_03,
    'schema': [
        {'name': 'col_001', 'type': 'STRING'},
        {'name': 'col_002', 'type': 'STRING'},
        {'name': 'col_003', 'type': 'string'},
        {'name': 'col_004', 'type': 'STRING'},
        {'name': 'col_005', 'type': 'STRING'},
    ],
}
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24031/0a4c418a-46f6-369a-fe94-41f31d712b7b.png)

```python
# カラム追加の処理が実行される想定
add_cols_to_spark_tbl(
    cols_conf = tbl_conf['schema'],
    tbl_name = tbl_conf['table_name'],
)
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24031/fdc99bb3-2f77-2b2b-aa41-ebeee256f832.png)

```python
# `col_005`が追加されたことを確認
spark.table(table_full_name_03).printSchema()
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24031/9afd2b75-a7b1-f0ac-d02c-93e79da811fa.png)

## Spark のデータオブジェクト のメタデータ取得方法

詳細は、 [Spark のデータオブジェクト のメタデータ取得方法 - Qiita](https://qiita.com/manabian/items/ee6fc39a723c36131d5b) の記事にて記載。

## リソースのクリーンアップ

```python
# データベースを作成
sql = f'DROP DATABASE IF EXISTS {db_name} CASCADE'
spark.sql(sql)
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24031/be5d534e-1ffd-a835-af45-41d68b78426d.png)