Elasticsearch Set Processorによるフィールド更新時の_source汚染問題の分析

問題背景

ある開発者がinteger型のフィールドにkeyword型のサブフィールドを追加し、正確なマッチングクエリを最適化して検索速度を向上させたいと考えていました。

インデックスのデータ量が大きくないため、reindexのような複雑な操作は避け、update_by_queryを使用して既存データを更新することにしました。

以下にテストした手順を示します。フィールドのサブフィールド設定後、set processorを使用してupdate_by_queryを実行しました。

操作記録:

# テストインデックスの作成
PUT /sample_index
{
  "mappings": {
    "properties": {
      "status_code": {
        "type": "integer"
      }
    }
  }
}
# テストデータの投入
POST /sample_index/_bulk
{"index":{}}
{"status_code":404}
{"index":{}}
{"status_code":500}

GET sample_index/_search

# サブフィールドの追加
PUT sample_index/_mapping
{
  "properties": {
    "status_code": {
      "type": "integer",
      "fields": {
        "keyword": {
          "type": "keyword"
        }
      }
    }
  }
}

GET sample_index/_search

# パイプラインの作成と更新ロジックの実装
PUT _ingest/pipeline/update_status_subfield
{
  "description": "status_codeフィールドとそのサブフィールドを更新",
  "processors": [
    {
      "set": {
        "field": "status_code",
        "value": "{{{status_code}}}"
      }
    }
  ]
}

#updateの実行
POST sample_index/_update_by_query?pipeline=update_status_subfield
{
  "query": {
    "bool": {
      "must_not": {
        "exists": {
          "field": "status_code.keyword"
        }
      },
      "must": {
        "exists": {
          "field": "status_code"
        }
      }
    }
  }
}


GET sample_index/_search
{
  "query": {
    "exists": {
      "field": "status_code.keyword"
    }
  }
}

# 返却結果
   "hits": [
      {
        "_index": "sample_index",
        "_type": "_doc",
        "_id": "G7zHNpUBLvnTvXTpVIC4",
        "_score": 1,
        "_source": {
          "status_code": "404"
        }
      },
      {
        "_index": "sample_index",
        "_type": "_doc",
        "_id": "HLzHNpUBLvnTvXTpVIC4",
        "_score": 1,
        "_source": {
          "status_code": "500"
        }
      }
    ]

テストの結果、status.keywordは検索可能であり、期待通りの動作を確認しました。

しかし、本番環境で問題が発生しました。アプリケーションが_source内のstatusの型が変更されたことを検知し、型不一致エラーが発生したのです。

# 更新前
    "hits": [
      {
        "_index": "sample_index",
        "_type": "_doc",
        "_id": "2ry5NpUBLvnTvXTp1F5z",
        "_score": 1,
        "_source": {
          "status_code": 404 # まだinteger型
        }
      },
      {
        "_index": "sample_index",
        "_type": "_doc",
        "_id": "27y5NpUBLvnTvXTp1F5z",
        "_score": 1,
        "_source": {
          "status_code": 500
        }
      }
    ]

# 更新後
   "hits": [
      {
        "_index": "sample_index",
        "_type": "_doc",
        "_id": "2ry5NpUBLvnTvXTp1F5z",
        "_score": 1,
        "_source": {
          "status_code": "404" # 引号が追加され、string型に
        }
      },
      {
        "_index": "sample_index",
        "_type": "_doc",
        "_id": "27y5NpUBLvnTvXTp1F5z",
        "_score": 1,
        "_source": {
          "status_code": "500"
        }
      }
    ]

解決策

幸いなことに、開発元にデータのプライマリ/セカンダリ環境があり、迅速な切り替えができました。その後、既存データの修復作業を開始しました。

最終的に以下の2つの解決策を決定しました。

  1. スクリプトを使用してデータ型を保持して書き換える
POST sample_index/_update_by_query
{
"script": {
  "source": """
    if (ctx._source.status_code instanceof String) {
      ctx._source.status_code = Integer.parseInt(ctx._source.status_code);
    }
  """,
  "lang": "painless"
  }
}

  1. 検索結果でdocvalueを読み込む代わりに_sourceを使用しない。この方法は問題を回避できますが、アプリケーションの変更が必要です。
GET sample_index/_search
{
  "_source": false,
  "docvalue_fields": [
    "status_code"
  ]
}

# 返却
    "hits": [
      {
        "_index": "sample_index",
        "_type": "_doc",
        "_id": "wLy-NpUBLvnTvXTpRGvw",
        "_score": 1,
        "fields": {
          "status_code": [
            404
          ]
        }
      },
      {
        "_index": "sample_index",
        "_type": "_doc",
        "_id": "wby-NpUBLvnTvXTpRGvw",
        "_score": 1,
        "fields": {
          "status_code": [
            500
          ]
        }
      }
    ]

問題分析

ここで、以前の手法で問題が発生した理由を分析しましょう。なぜ set processor が source 内のフィールド型を int から string に変更してしまったのでしょうか?

スクリプト手法は成功しましたが、set は失敗したため、set の使用方法から始めて、コード内に何か手がかりがないか探ります。

set processor 問題の詳細

値型変換のコアコードパスを深く分析してみましょう:

// SetProcessor.java
document.setFieldValue(field, value, ignoreEmptyValue);

ここでの value パラメータの型は ValueSource です、

// SetProcessor.Factory.create()
Object value = ConfigurationUtils.readObject(TYPE, processorTag, config, "value");
ValueSource valueSource = ValueSource.wrap(value, scriptService);

その核心的な実装ロジックは ValueSource.java インターフェースにあります:

// ValueSource.java 関メソッド 59行
public static ValueSource wrap(Object value, ScriptService scriptService) {
    ......
    } else if (value instanceof String) {
            // このチェックはDEFAULT_TEMPLATE_LANG(mustache)が
            // RESTテストで使用するためにインストールされていない場合に存在します
            // テンプレートが利用できない場合、valueは変更されません
            if (scriptService.isLangSupported(DEFAULT_TEMPLATE_LANG) && ((String) value).contains("{{")) {
                Script script = new Script(ScriptType.INLINE, DEFAULT_TEMPLATE_LANG, (String) value, Collections.emptyMap());
                return new TemplatedValue(scriptService.compile(script, TemplateScript.CONTEXT));
            } else {
                return new ObjectValue(value);
            }
        }
        ......
}

設定値のvalueが"{{{status_code}}}"文字列の場合、TemplateValueインスタンスが作成されます。

ここでの"{{{status_code}}}"記法はMustache構文であり、軽量なテンプレートエンジン構文です。ESはsearch templateで主に使用されますが、set processorでもMustacheを使用してフィールド内容を参照しています。

// ValueSource.java 内部
private static class TemplateValue extends ValueSource {
    private final TemplateScript.Factory template;

    @Override
        public Object copyAndResolve(Map<String, Object> model) {
            return template.newInstance(model).execute();
        }

}

さらに抽象クラスTemplateScript.java#execute()メソッドを見ると、このメソッドはstringを返すと明確に宣言されています。

    /** テンプレートを実行し、utf8バイトでエンコードされた結果の文字列を返します。 */
    public abstract String execute();

実装サブクラスは明らかにMustacheExecutableScript.execute()であり、Mustache構文エンジンの実装です。

private class MustacheExecutableScript extends TemplateScript {
  ......
  @Override
        public String execute() {
            final StringWriter writer = new StringWriter();
            try {
                // ここでのリフレクション
                SpecialPermission.check();
                AccessController.doPrivileged((PrivilegedAction<Void>) () -> {
                    template.execute(writer, params);
                    return null;
                });
            } catch (Exception e) {
                logger.error((Supplier<?>) () -> new ParameterizedMessage("Error running {}", template), e);
                throw new GeneralScriptException("Error running " + template, e);
            }
            return writer.toString();
        }
    ......

ここでもフィールド内容の型が強制的にstringに変換されていることが確認できます。

型変換プロセス

型変換プロセスは以下のようになります:

sequenceDiagram
    participant SetProcessor
    participant ValueSource
    participant TemplateValue
    participant TemplateScript
    participant MustacheEngine

    SetProcessor->>ValueSource: wrap("{{status_code}}")
    ValueSource->>TemplateValue: インスタンス作成
    SetProcessor->>TemplateValue: copyAndResolve(doc)
    TemplateValue->>TemplateScript: newInstance(doc)
    TemplateScript->>MustacheEngine: compile("{{status_code}}")
    MustacheEngine-->>TemplateScript: コンパイル済みMustacheテンプレート実装を返却
    TemplateValue->>TemplateScript: execute()
    MustacheEngine-->>TemplateScript: ここで結果をStringとしてレンダリング
    TemplateValue-->>SetProcessor: "200"(String)を返却

まとめ

_source内のフィールド型が変換された原因は、ESがset processorで使用するMustache構文の結果値を特殊に処理し、すべての内容をstring型にしているためです。

もしsetで処理する値がデフォルト値404のような固定値であれば、この問題は発生しません

PUT _ingest/pipeline/update_status_fixed_value
{
  "description": "status_codeフィールドを固定値で更新",
  "processors": [
    {
      "set": {
        "field": "status_code",
        "value": 404
      }
    }
  ]
}

#updateの実行方法
POST sample_index/_update_by_query?pipeline=update_status_fixed_value
{
  "query": {
    "bool": {
      "must_not": {
        "exists": {
          "field": "status_code.keyword"
        }
      },
      "must": {
        "exists": {
          "field": "status_code"
        }
      }
    }
  }
}

GET sample_index/_search
# 返却内容
      {
        "_index": "sample_index",
        "_type": "_doc",
        "_id": "tN0QRZUBLvnTvXTpJMTI",
        "_score": 1,
        "_source": {
          "status_code": 404
        }
      },
      {
        "_index": "sample_index",
        "_type": "_doc",
        "_id": "td0QRZUBLvnTvXTpJMTI",
        "_score": 1,
        "_source": {
          "status_code": 404
        }
      }

では、ESのset processorにおけるMustache構文処理でstringを返値として使用するのは適切でしょうか?元のデータ型を保持する必要がある場合、TemplateScript.java#execute()メソッドを変更せずに実現可能でしょうか?

タグ: Elasticsearch Set Processor _source mustache Painless Script

5月28日 01:13 投稿