Elastic Stack コードリーディング: Painless スクリプト

最近は Elastic Stack のソースコードを読むのが趣味になりつつあります。今日は **Painless** 。以前から気になっていた疑問や、調査中に派生した疑問を追ってみました。

1. Java API 使えるのに、なぜ `java.lang.String.split` が使えない? 
1. Painless での `dissect` や `grok` の実装はどうなってる?
1. Runtime field で他のインデックスを参照できるようになる!? `lookup`
1. 各コンテキストで利用できる API を手っ取り早く探したい
1. Kibana Painless Lab の使い所...
1. Runtime field でもスクリプトを保存しておいて使いまわしたいのだが...


なるべく該当箇所のソースコードへリンクを付けてます。

## 1. Java API 使えるのに、なぜ `java.lang.String.split` が使えない? 

[正規表現を安心して使える](https://www.elastic.co/guide/en/elasticsearch/painless/master/painless-regexes.html) ように利用を制限している。

Painless では [java.lang.txt](https://github.com/elastic/elasticsearch/blob/15708d5454f58058ce386b1c5b131b5f0092a56f/modules/lang-painless/src/main/resources/org/elasticsearch/painless/java.lang.txt#L736-L751) のようなファイルが沢山あり、利用できる API を制限している。また、 `splitOnToken` のような Painless 独自のメソッドも注入している。
```java
class java.lang.String {
  ()
  int codePointAt(int)
  int codePointBefore(int)
  (中略)
  String org.elasticsearch.painless.api.Augmentation decodeBase64()
  String org.elasticsearch.painless.api.Augmentation encodeBase64()
  String[] org.elasticsearch.painless.api.Augmentation splitOnToken(String)
  String[] org.elasticsearch.painless.api.Augmentation splitOnToken(String, int)
```

Painless では各コンテキストで利用可能な API が異なる。どこで、何が使えるかは [painless-string_script_field_script_field.json](https://github.com/elastic/elasticsearch/blob/master/modules/lang-painless/src/main/generated/whitelist-json/painless-string_script_field_script_field.json) のようなコンテキストごとのファイルがあり、これで管理している様子。自動生成されているっぽいけれどどこで紐付けられているかは未開拓。

Runtime field では出力するデータ型によってコンテキストの実装が分かれている。 `keyword` 型のコンテキストは [StringFieldScript](https://github.com/elastic/elasticsearch/blob/master/server/src/main/java/org/elasticsearch/script/StringFieldScript.java) 。

## 2. Painless での `dissect` や `grok` の実装はどうなってる?

[dissect](https://github.com/elastic/elasticsearch/blob/master/libs/dissect/src/main/java/org/elasticsearch/dissect/DissectParser.java) と [grok](https://github.com/elastic/elasticsearch/blob/master/libs/grok/src/main/java/org/elasticsearch/grok/Grok.java) は [NamedGroupExtractor]() という仕組みで Painless と繋がっている。 Runtime field 向けの [white list](https://github.com/elastic/elasticsearch/blob/master/modules/runtime-fields-common/src/main/resources/org/elasticsearch/runtimefields/common_whitelist.txt)。

## 3. Runtime field で他のインデックスを参照できるようになる!? `lookup`

Painless の公式ドキュメント master ブランチを見ると、 [lookup](https://www.elastic.co/guide/en/elasticsearch/reference/master/runtime-retrieving-fields.html#lookup-runtime-fields) が追加されている。2022-03-11 時点ではまだリリースされていないが、これは便利！

## 4. 各コンテキストで利用できる API を手っ取り早く探したい

Painless のドキュメントで [使える API](https://www.elastic.co/guide/en/elasticsearch/painless/master/painless-api-reference-shared-java-time.html) を探しても良いのだが、リンクを行ったりきたりするのがちょっと面倒な時も。

今回ソースコードを見ていて面白い内部向け API エンドポイント、 [PainlessContextAction](https://github.com/elastic/elasticsearch/blob/master/modules/lang-painless/src/main/java/org/elasticsearch/painless/action/PainlessContextAction.java) を発見。

```bash
# コンテキストの一覧を取得
GET /_scripts/painless/_context

# レスポンス
{
  "contexts" : [
    "aggregation_selector",
    "aggs",
    "aggs_combine",
    "aggs_init",
...
    "keyword_field",
(以下略)
```

コンテキスト毎の情報を取得するには:

```
GET /_scripts/painless/_context?context=keyword_field
```

Painless スクリプト書いている時にもう一枚 Kibana の Console を開いておいて確認すると便利。 `"name" : "java.time.LocalDateTime"` などで検索すると使えるメソッドの一覧が確認できる。`imported: true` のクラスはいちいちパッケージ書かなくてもクラス名だけで参照可能。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/20515/29bc4cb8-49d8-a2a5-c683-e1850f66d38b.png)

## 5. Kibana Painless Lab の使い所...

Painless Lab ではコンテキスト選んで Painless を実行できる。のだけど、実行時のコンテキストに大きく依存するのと、インデックスを選んだり、サンプルドキュメントを記述する UI 部品が分かれていたり、一つしか記憶してくれないとか、Filter コンテキストのテストなどは結果が true/false しか分からず、スクリプトで参照している値が確認できないなど、ちょっと使いづらい感じ。

Kibana Painless Lab では、 [PainlessExecuteAction](https://github.com/elastic/elasticsearch/blob/master/modules/lang-painless/src/main/java/org/elasticsearch/painless/action/PainlessExecuteAction.java) の API を呼び出している。ソースコードを見ると、 Kibana からは指定できない Runtime field も API ではサポートしていることを発見。

フィールドのデータ型を参照するために、インデックスは事前に存在する必要があるが、テスト用のドキュメントを指定できたり、 `emit()` を使えば任意の変数をデバッグできたりして重宝するかも。

そして Dev Console から実行するので記録に残しやすい。

例えばこんな感じで実行できる:

```bash
# 何月?
POST _scripts/painless/_execute
{
  "script": {
    "source": """
    def timestamp = doc['order_date'].value;
    def localTimestamp = timestamp.withZoneSameInstant(ZoneId.of('Asia/Tokyo')).toLocalDateTime();
    emit(localTimestamp.getMonthValue());
    """
  },
  "context": "long_field",
  "context_setup": {
    "index": "kibana_sample_data_ecommerce",
    "document": {
      "order_date": "2022-03-11T15:17:23.123+09:00"
    }
  }
}

# 結果
{
  "result" : [
    3
  ]
}
```

Runtime field は `_search` 実行時に定義できるので、すでにドキュメントが保存されているなら検索リクエストでテストするのも良い。

```bash
GET kibana_sample_data_ecommerce/_search
{
  "size": 1,
  "_source": false,
  "fields": [
    "order_date", "month"
  ],
  "runtime_mappings": {
    "month": {
      "type": "long",
      "script": {
        "source": """
        def timestamp = doc['order_date'].value;
        def localTimestamp = timestamp.withZoneSameInstant(ZoneId.of('Asia/Tokyo')).toLocalDateTime();
        emit(localTimestamp.getMonthValue());
        """
      }
    }
  }
}

# 結果
{
  "took" : 2,
  ...
  "hits" : {
    ...
    "hits" : [
      {
        "_index" : "kibana_sample_data_ecommerce",
        "_id" : "HI7IiX4BdgfmkI_X6zlv",
        "_score" : 1.0,
        "fields" : {
          "order_date" : [
            "2022-02-07T09:28:48.000Z"
          ],
          "month" : [
            2
          ]
        }
      }
    ]
  }
}
```

## 6. Runtime field でもスクリプトを保存しておいて使いまわしたいのだが...

タイムスタンプから、年月や曜日、時間だけを切り出したいというのはよくある話。 ID 指定で保存したスクリプトを呼び出して、いろいろな Runtime field で使いまわせたら便利。

Ingest node pipeline なら、呼出時にパラメータで対象のフィールドや、取得単位を指定する形で、コンパイル回数も最小にできる再利用可能な部品が定義できる。 [保存したスクリプトを Ingest node pipeline で再利用する例](https://gist.github.com/ijokarumawak/c5385736ce3c46638568ea738fbbfba6)。

Runtime field でも同じ様なことできないか試してみたら以下のエラーに。 [残念](https://github.com/elastic/elasticsearch/blob/15708d5454f58058ce386b1c5b131b5f0092a56f/server/src/main/java/org/elasticsearch/index/mapper/RuntimeField.java#L233)！

> stored scripts are not supported for runtime field

コード読んでると何か作りたくなってくるなー。