Elasticsearchでの曜日検索のためにPainless Scriptと戦った話


はじめに

オークファン新卒2年目エンジニアの @tmot です。

オークファンでは、大量のオークションデータが保存されたElasticsearchクラスタを持ち、そのデータを活用して様々なサービスを提供しています。私は主にバックエンドの開発に携わっているので、Elasticsearchと対話しなければならない業務も多いです。
そんなElasticsearchとの格闘戯れの日々の中で、日付に関する検索・集計で苦戦したことがあったので、少し書いていきたいと思います。

環境

使用OS: Windows10
Elasticsearch: 6.2.4
Kibana: 6.2.4

昔作成されたElasticsearchクラスタを扱う業務が直近であったため、Elastic Stackは少し古い6.2.4となっています。

データ

業務で実際に扱っているデータはお見せできないので、今回は、item_sample_indexというインデックスに簡素化したサンプルデータを作成し、使用します。
シンプルなオークション出品データです。

  • フィールド
フィールド名 データ型 説明
id keyword 出品ID
title text 商品名
price scaled_float 価格
bids integer 入札数
startDateTime date 出品開始日時
endDateTime date 出品終了日時
データの例
{
  "id": "item-tuesday-01",
  "title": "火曜日までだよ",
  "price": 100,
  "quantity": 1,
  "bids": 1,
  "startDateTime": "2020-11-24T19:00:00+09:00",
  "endDateTime": "2020-12-01T19:00:00+09:00"
}

このような出品データを、出品終了日時が2020-12-01(火) ~ 2020-12-18(金)の期間で1日につき1レコード作成します。時刻はhh:00:00とし、hour部分を0~23でランダムに設定しておきます。

検索

日付で検索する

出品終了日時が特定の日付であるデータが欲しいときは、rangeで範囲指定してやればよいです。

request
GET item_sample_index/_search
{
  "query": {
    "range": {
      "endDateTime": {
        "gte": "2020-12-01T00:00:00+09:00",
        "lt": "2020-12-02T00:00:00+09:00"
      }
    }
  }
}
response
{
  ...
  "hits": {
    "total": 1,
    "max_score": 1,
    "hits": [
      {
        "_index": "item_sample_index",
        "_type": "item",
        "_id": "Rfz8YHYBYbdd_Ev6-ddK",
        "_score": 1,
        "_source": {
          "id": "item-tuesday-01",
          "title": "火曜日までだよ",
          "price": 100,
          "quantity": 1,
          "bids": 1,
          "startDateTime": "2020-11-24T19:00:00+09:00",
          "endDateTime": "2020-12-01T19:00:00+09:00"
        }
      }
    ]
  }
}

曜日で検索する

今度は出品終了日時の曜日で検索します。日付そのままではできなさそうなので、scriptクエリでendDateTimeの曜日を取って検索しようと思います。

まずは小手調べ

scriptクエリに使用するpainless scriptではJavaのように書くことができるようなので、試しに書いてみます。
java.timeライブラリが使えるとのことなので、日付をjava.time.DateTimeクラスとかに変換してくれたりしたら助かるなと若干期待しながら書いてみます。

request
GET item_sample_index/_search
{
  "query": {
    "script": {
      "script": """
      // 勝手にキャストしてくれないかな~
      OffsetDateTime dateTime = doc['endDateTime'].value;
      dateTime.dayOfWeek == DayOfWeek.TUESDAY
      """
    }
  }
}
response
{
  "error": {
    ...
          "caused_by": {
            "type": "class_cast_exception",
            "reason": "Cannot cast org.joda.time.MutableDateTime to java.time.OffsetDateTime"
          }
        }
      }
    ]
  },
  "status": 500
}

一度でできるとは思っていなかったのでまあよし。
doc['endDateTime'].valueorg.joda.time.MutableDateTimeとして読まれるのですね。

MutableDateTimeクラスを活用する

org.joda.time.MutableDateTimeで取れるようなので、このクラスのdayOfWeekを使えばできそうです。
以下のようにスクリプトを書き換えました。

script
  "script": """
  DayOfWeek dayOfWeek = DayOfWeek.of(doc['endDateTime'].value.dayOfWeek);
  dayOfWeek == DayOfWeek.TUESDAY
  """
response
{
  ...
  "hits": {
    "total": 4,
    "max_score": 1,
    "hits": [
      {
        "_index": "item_sample_index",
        "_type": "item",
        "_id": "Rfz8YHYBYbdd_Ev6-ddK",
        "_score": 1,
        "_source": {
          "id": "item-tuesday-01",
          "title": "火曜日までだよ",
          "price": 100,
          "quantity": 1,
          "bids": 1,
          "startDateTime": "2020-11-24T19:00:00+09:00",
          "endDateTime": "2020-12-01T19:00:00+09:00"
        }
      },
      {
        "_index": "item_sample_index",
        "_type": "item",
        "_id": "R_z9YHYBYbdd_Ev649cg",
        "_score": 1,
        "_source": {
          "id": "item-tuesday-03",
          "title": "火曜日までだよ",
          "price": 100,
          "quantity": 1,
          "bids": 1,
          "startDateTime": "2020-12-08T18:00:00+09:00",
          "endDateTime": "2020-12-15T18:00:00+09:00"
        }
      },
      {
        "_index": "item_sample_index",
        "_type": "item",
        "_id": "Svz_YHYBYbdd_Ev6UNck",
        "_score": 1,
        "_source": {
          "id": "item-wednesday-03",
          "title": "水曜日までだよ",
          "price": 100,
          "quantity": 1,
          "bids": 1,
          "startDateTime": "2020-12-09T00:00:00+09:00",
          "endDateTime": "2020-12-16T00:00:00+09:00"
        }
      },
      {
        "_index": "item_sample_index",
        "_type": "item",
        "_id": "Rvz9YHYBYbdd_Ev6hNfA",
        "_score": 1,
        "_source": {
          "id": "item-tuesday-02",
          "title": "火曜日までだよ",
          "price": 100,
          "quantity": 1,
          "bids": 1,
          "startDateTime": "2020-12-01T21:00:00+09:00",
          "endDateTime": "2020-12-08T21:00:00+09:00"
        }
      }
    ]
  }
}

できた!!
と思いきや…

 {
   "_index": "item_sample_index",
   "_type": "item",
   "_id": "Svz_YHYBYbdd_Ev6UNck",
   "_score": 1,
   "_source": {
     "id": "item-wednesday-03",
     "title": "水曜日までだよ",
     "price": 100,
     "quantity": 1,
     "bids": 1,
     "startDateTime": "2020-12-09T00:00:00+09:00",
     "endDateTime": "2020-12-16T00:00:00+09:00"
   }
 }

水曜日のものも混ざってしまっていますね…。

タイムゾーンを合わせる

ElasticsearchはUTCで時刻を保持しているので、UTC基準で曜日集計してしまいます。
例えば先ほどの2020-12-16T00:00:00+09:00は、2020-12-15T15:00:00Zとして保存されているため、火曜日として集計されてしまったのです。
MutableDateTime.setZone(DateTimeZone newZone)でタイムゾーンをJSTに変換してみます。

script
"script": """
      DateTimeZone dateTimeZone = new DateTimeZone('Asia/Tokyo');
      DayOfWeek dayOfWeek = DayOfWeek.of(doc['endDateTime'].value.setZone(dateTimeZone).dayOfWeek);
      dayOfWeek == DayOfWeek.TUESDAY
      """

実行。

response
{
  "error": {
   ...
            "caused_by": {
              "type": "illegal_argument_exception",
              "reason": "unexpected token ['dateTimeZone'] was expecting one of [{<EOF>, ';'}]."
            }
          }
        }
      }
    ]
  },
  "status": 400
}

?

OffsetDateTimeに変換する

Elasticsearchのissueを見たところ、org.joda.time.MutableDateTimeはpainlessには存在せず(!?)、org.joda.time.ReadableDateTimeをJavaのTimeクラスに変換する必要があるとのこと。

最初に出てきた"reason": "Cannot cast org.joda.time.MutableDateTime to java.time.OffsetDateTime"というエラーメッセージは誤りだったのですね…。

java.timeクラスに変換して扱うという方針で、以下のようにスクリプトを修正しました。

script
      "script": """
      OffsetDateTime time = OffsetDateTime.parse(doc['endDateTime'].value.toString()).withOffsetSameInstant(ZoneOffset.ofHours(9));
      time.dayOfWeek == DayOfWeek.TUESDAY
      """
response
{
 ...
  "hits": {
    "total": 3,
    "max_score": 1,
    "hits": [
      {
        "_index": "item_sample_index",
        "_type": "item",
        "_id": "Rfz8YHYBYbdd_Ev6-ddK",
        "_score": 1,
        "_source": {
          "id": "item-tuesday-01",
          "title": "火曜日までだよ",
          "price": 100,
          "quantity": 1,
          "bids": 1,
          "startDateTime": "2020-11-24T19:00:00+09:00",
          "endDateTime": "2020-12-01T19:00:00+09:00"
        }
      },
      {
        "_index": "item_sample_index",
        "_type": "item",
        "_id": "R_z9YHYBYbdd_Ev649cg",
        "_score": 1,
        "_source": {
          "id": "item-tuesday-03",
          "title": "火曜日までだよ",
          "price": 100,
          "quantity": 1,
          "bids": 1,
          "startDateTime": "2020-12-08T18:00:00+09:00",
          "endDateTime": "2020-12-15T18:00:00+09:00"
        }
      },
      {
        "_index": "item_sample_index",
        "_type": "item",
        "_id": "Rvz9YHYBYbdd_Ev6hNfA",
        "_score": 1,
        "_source": {
          "id": "item-tuesday-02",
          "title": "火曜日までだよ",
          "price": 100,
          "quantity": 1,
          "bids": 1,
          "startDateTime": "2020-12-01T21:00:00+09:00",
          "endDateTime": "2020-12-08T21:00:00+09:00"
        }
      }
    ]
  }
}

今度こそできましたね。

集計

先ほどの曜日検索に用いたスクリプトを少し変えてやれば曜日ごとの集計にも使えます。

request
GET item_sample_index/_search
{
  "aggs": {
    "dayOfWeekAggregation": {
      "terms": {
        "script": {
          "source": """
          OffsetDateTime time = OffsetDateTime.parse(doc['endDateTime'].value.toString()).withOffsetSameInstant(ZoneOffset.ofHours(9));
          time.dayOfWeek
          """
        }
      }
    }
  },
  "size": 0
} 
response
{
  "took": 24,
  "timed_out": false,
  "_shards": {
    "total": 5,
    "successful": 5,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": 18,
    "max_score": 0,
    "hits": []
  },
  "aggregations": {
    "dayOfWeekAggregation": {
      "doc_count_error_upper_bound": 0,
      "sum_other_doc_count": 0,
      "buckets": [
        {
          "key": "FRIDAY",
          "doc_count": 3
        },
        {
          "key": "THURSDAY",
          "doc_count": 3
        },
        {
          "key": "TUESDAY",
          "doc_count": 3
        },
        {
          "key": "WEDNESDAY",
          "doc_count": 3
        },
        {
          "key": "MONDAY",
          "doc_count": 2
        },
        {
          "key": "SATURDAY",
          "doc_count": 2
        },
        {
          "key": "SUNDAY",
          "doc_count": 2
        }
      ]
    }
  }
}

おまけ: Elasticsearch 7.6.1で試す

エラーメッセージに誤りがあったと思われる最初のクエリを、バージョン7.6.1 (別プロジェクトで少し前に使用していた)でも試してみます。

request
GET item_sample_index/_search
{
  "query": {
    "script": {
      "script": """
      OffsetDateTime dateTime = doc['endDateTime'].value;
      dateTime.dayOfWeek == DayOfWeek.TUESDAY
      """
    }
  }
}
response
{
  "error" : {
          ...
          "caused_by" : {
            "type" : "class_cast_exception",
            "reason" : "Cannot cast org.elasticsearch.script.JodaCompatibleZonedDateTime to java.time.OffsetDateTime"
          }
        }
      }
    ]
  },
  "status" : 400
}

Elasticsearchのライブラリ内のクラスにキャストされるように変更されていますね!

以下のようなスクリプトで曜日検索ができました。かなり書きやすくなった印象。

script
      "script": "doc['endDateTime'].value.withZoneSameInstant(ZoneId.of('Asia/Tokyo')).getDayOfWeek() == DayOfWeek.TUESDAY"

最後に

試行錯誤しているうちにElasticsearchのスクリプトの扱いに少し慣れてきた気がします。
スクリプトを使うと検索速度が落ちるという問題はあるので、できるだけスクリプトを使わずに済むのが一番ですが、今後必要になったときに今回の知見を生かせる場面があればと思います。