【Elasticsearch】Date detectionの話

こんにちは! ZAICO開発チームです。

最近、Amazon Elasticsearch Service (Amazon ES) を使い始めました。
少し注意が必要そうな挙動がありましたので、その話を書こうと思います。

Elasticsearch (ES) の大きな特徴として、Dynamic Mapping があります。
これは初回投入されたデータから、ESが適切なフィールドの型を推測して自動的に定義してくれる、というものです。

なので、ESではRDBのように事前にカラムやインデックスを追加しておく必要がない、ということです。
(いわゆる、スキーマレスってやつですね!)

ただし、これには注意が必要で、状況によってはデータが正常に登録されずロストしてしまう、といった事態に陥ります。

自分がそうでした。

何が起きたか?

かなり簡略化しますが、以下のような構造のデータをインデックスに投入してました。

{
  "name": "在庫太郎",
  "tel": "03-0000-0000",
  "memo": "これは在庫テストです"
}

仕様変更が入り、新たなフィールドbirthdayを追加し、データ投入しました。

{
  "name": "テスト太郎",
  "tel": "03-0000-0000",
  "memo": "これはテストです",
  "birthday": "1990-01-01"
}

幸か不幸か、birthdayフィールドに最初に投入したデータがdate型(1990-01-01)の文字列でした。

先にオチを書いてしまいますが、このときESが気を利かせて、フィールド型をdate型にしてくれていました。

この挙動は、Date detectionという機能で、ESが日付らしい文字列を検出すると、自動的にdate型としてフィールドの型を定義してくれる、というもので、デフォルトでこの設定は有効になっています。

そして、次に投入されたデータが「”birthday”: “1月”」のような、誕生月の文字列でした。

{
  "name": "検証次郎",
  "tel": "03-0000-0000",
  "memo": "これは検証です",
  "birthday": "1月"
}

確かに、アプリケーション側ではユーザーの自由入力欄としていたため、これは仕様通りで、データ自体は別に悪くないです。

しかし当然ながら、これはdate型ではないのでES側としてはエラーとなり、登録処理に失敗していました。

エラー内容

{"error":{"root_cause":[{"type":"mapper_parsing_exception","reason":"failed to parse field [birthday] of type [date] in document with id '2'. Preview of field's value: '1月'"}],"type":"mapper_parsing_exception","reason":"failed to parse field [birthday] of type [date] in document with id '2'. Preview of field's value: '1月'","caused_by":{"type":"illegal_argument_exception","reason":"failed to parse date field [1月] with format [strict_date_optional_time||epoch_millis]","caused_by":{"type":"date_time_parse_exception","reason":"Failed to parse with all enclosed parsers"}}},"status":400}

つまりbirthdayフィールドは意図せず、date型(YYYY-MM-DD)の値しか受け付けないフィールドになってしまっていた、ということですね。

さて、どう対策したものか・・。色々と方法は考えられましたが、対応方針としては、今後メンテなどの作業が発生しにくい設計にすることでした。

Date detectionを無効化しよう

最初に思いついたのは、アプリケーション側で入力制限(date型のみ入力許可)をかけてしまう方法でした。

しかしながら、今回はユーザーに自由入力させたい、という要件があったため、これは却下。

次に、今回追加したフィールド(birthday)の型をtext型に変更してしまうのはどうか、と考えました。

しかしながら、(これは後にESの仕様上できないことが判明するのですが)そもそも、今後フィールド追加する度に、初回投入するデータに気を配ったり、個別にメンテとかしたくない、という思いが強く、やはりこれも却下しました。

色々と調査した結果、Date detectionの無効化、およびDynamic templatesの設定を行うことで求める挙動を実現できそうでした。

Dynamic templatesとは?

Dynamic templatesは、マッピングルールを事前登録しておくことで、将来追加される未知のフィールドに対して、そのルールに基づいた型の定義を適用してくれる便利な機能です。

例えば、以下のような設定(dynamic_templatesの部分)をしておくと、以降entry_dateentryDateといった新たなフィールド名でデータ投入された際に、そのフィールドをdate型として定義してくれるようになります。

$ curl -XPUT 'https://endpoint/test_v1' -H 'Content-Type: application/json' -d'
{
  "mappings": {
    "date_detection": false,
    "dynamic_templates": [
      {
        "dates": {
          "match": ".*Date|.*_date|date",
          "match_pattern": "regex",
          "mapping": {
            "type": "date"
          }
        }
      }
    ]
  }
}'

こうすることで、Date detectionの無効化によって恩恵を受けられなくなったdate型の自動検出の代わりに、フィールド名のネーミングルールによって明示的にdate型を指定することができます。

では早速、上記実行してマッピング設定を変更してみます。

が、残念ながら、ESの仕様上、既存インデックスのマッピングは変更できないようで、以下のように怒られました。

{"error":{"root_cause":[{"type":"resource_already_exists_exception","reason":"index [test_v1/****-****] already exists","index_uuid":"****-****","index":"test_v1"}],"type":"resource_already_exists_exception","reason":"index [test_v1/****-****] already exists","index_uuid":"****-****","index":"test_v1"},"status":400}

色々と事情があるのでしょう。

新たにインデックス作成

仕方がないので、新たにインデックス(test_v2)を作成しました。

改めて、以下実行します。(Date detectionの無効化&dynamic_templatesの設定を忘れずに)

$ curl -XPUT 'https://endpoint/test_v2' -H 'Content-Type: application/json' -d'
{
  "mappings": {
    "date_detection": false,
    "dynamic_templates": [
      {
        "dates": {
          "match": ".*Date|.*_date|date",
          "match_pattern": "regex",
          "mapping": {
            "type": "date"
          }
        }
      }
    ]
  }
}'

作成成功。

テストしてみる

ではここからは、意図した通りにインデックスしてくれているか、確認していきます。

まず、今回と同じ状況を再現するために、初回データではbirthdayの値をdate型文字列として投入してみます。

$ curl -XPUT 'https://endpoint/test_v2/_doc/1' -H 'Content-Type: application/json' -d '
{
  "name": "テスト太郎",
  "tel": "03-0000-0000",
  "memo": "これはテストです",
  "birthday": "1990-01-01"
}'

次に、birthdayを「1月」のような文字列で投入してみます。

旧インデックス(test_v1)では、この時点でbirthdayの型がdate型に自動判定されてしまっていたため、この登録はエラーとなっていました。

$ curl -XPUT 'https://endpoint/test_v2/_doc/2' -H 'Content-Type: application/json' -d '
{
  "name": "検証次郎",
  "tel": "03-0000-0000",
  "memo": "これは検証です",
  "birthday": "1月"
}'

ちゃんと登録されました!

最後に、新たなフィールドentry_dateをdate型文字列でデータ投入します。

$ curl -XPUT 'https://endpoint/test_v2/_doc/3' -H 'Content-Type: application/json' -d '
{
  "name": "三郎",
  "tel": "03-0000-0000",
  "memo": "これは検証です",
  "birthday": "2月",
  "entry_date": "2021-02-25"
}'

これも成功。

念のため、test_v2のマッピングを確認してみます。

$ curl -XGET 'https://endpoint/test_v2/_mapping?pretty'
{
  "test_v2" : {
    "mappings" : {
      "dynamic_templates" : [
        {
          "dates" : {
            "match" : ".*Date|.*_date|date",
            "match_pattern" : "regex",
            "mapping" : {
              "type" : "date"
            }
          }
        }
      ],
      "date_detection" : false,
      "properties" : {
        "birthday" : {
          "type" : "text",
          "fields" : {
            "keyword" : {
              "type" : "keyword",
              "ignore_above" : 256
            }
          }
        },
        "entry_date" : {
          "type" : "date"
        },
        "memo" : {
          "type" : "text",
          "fields" : {
            "keyword" : {
              "type" : "keyword",
              "ignore_above" : 256
            }
          }
        },
        "name" : {
          "type" : "text",
          "fields" : {
            "keyword" : {
              "type" : "keyword",
              "ignore_above" : 256
            }
          }
        },
        "tel" : {
          "type" : "text",
          "fields" : {
            "keyword" : {
              "type" : "keyword",
              "ignore_above" : 256
            }
          }
        }
      }
    }
  }
}

ちゃんとentry_dateがdate型になってますね!birthdayもtext型で意図した通りの状態です。

あとは、アプリケーションから参照しているインデックス名を新しいものに変更(test_v1 → test_v2)してあげれば作業完了です。

これで今後、新たにフィールド追加した際、意図せずdate型にされてしまうことはないはずです。

余談(エイリアス設定)

余談ですが、エイリアスを設定しておくと、今回のようにインデックスを新たに作成した場合の切り替え作業が楽になります。

以下のようにtest_indexというエイリアスを作成し、アプリケーションからはこのエイリアス名で参照させておきます。

$ curl -XPOST 'https://endpoint/_aliases' -H 'Content-Type: application/json' -d'
{
  "actions" : [
    { "add" : { "index" : "test_v1", "alias" : "test_index" } }
  ]
}'

インデックスの切り替えは、以下のようにエイリアスを付け替えることで実施できます。

$ curl -XPOST 'https://endpoint/_aliases' -H 'Content-Type: application/json' -d'
{
  "actions" : [
    { "remove" : { "index" : "test_v1", "alias" : "test_index" } },
    { "add"    : { "index" : "test_v2", "alias" : "test_index" } }
  ]
}'

これにより、アプリケーションの修正不要、ダウンタイムなしでインデックスの切り替えが可能になります。

まとめ

今回、Date detectionの無効化という割り切った対応を行いましたが、Dynamic templatesと組み合わせることで、ES機能の恩恵を受けつつ、アプリケーション側の拡張性も保つことができるのではないかと思います。