こんにちは。19BのSysAd班のtokiです。
この記事はSysAdTechBlogの第4回目の記事です。
先日traQに検索機能を実装していたら、突然謎のエラーを吐いてしまった話をします。
traQの検索機能
traQにメッセージ検索機能を搭載する案は昔からありましたが、つい先日、2021年3月13日にこれを実装したクライアントv3.8.0とサーバーv3.6.0がリリースされました。
サーバー側の検索機能はElasticsearchを用いて実装されています。リリース当初は良い感じに動いているかのように見えたのですが、すぐに日本語検索が上手く機能しないことに気づきました。
上の例では、「かわいい」という単語が「か」「わ」「い」「い」に分割(tokenize)されて、「か & わ & い & い」で検索がかかってしまっています。Elasticsearchはデフォルトでは日本語テキストのtokenizeとanalyzeを上手くやってくれません。
ちなみに「"かわいい"」といった感じにdouble quote"
で囲むと、完全一致での検索はしてくれます。
が、単語の活用や表記ゆれなどもあるので、流石にこれでは使い勝手が悪そうです。
そこで、日本語tokenizerであるSudachiと、そのElasticsearchプラグインを使用するように、以下のプルリクエストが出されました。
これでいい感じに日本語のメッセージを検索できるぞ!と、検証段階に入ったときに事件は起きました。
java.lang.StackOverflowError
本番環境に実際に存在する、約130万メッセージのコピーが手元にいい感じにあった[1]ので、これらを実際にElasticsearchに食わせてみることにしました。
が、メッセージを食わせている途中で突然Elasticsearchが以下のようなエラーを吐いて落ちてしまいました。
{"type": "server", "timestamp": "2021-03-18T06:08:03,325Z", "level": "ERROR", "component": "o.e.b.ElasticsearchUncaughtExceptionHandler", "cluster.name": "docker-cluster", "node.name": "cc43cd0817aa", "message": "fatal error in thread [elasticsearch[cc43cd0817aa][write][T#1]], exiting", "cluster.uuid": "ABZQm9QQQq2YQPyigw68_w", "node.id": "fl2YrnfBRHyo45FEFNej0w" ,
"stacktrace": ["java.lang.StackOverflowError: null",
"at java.util.regex.Pattern$Branch.match(Pattern.java:4800) ~[?:?]",
"at java.util.regex.Pattern$GroupHead.match(Pattern.java:4855) ~[?:?]",
"at java.util.regex.Pattern$Loop.match(Pattern.java:4964) ~[?:?]",
"at java.util.regex.Pattern$GroupTail.match(Pattern.java:4886) ~[?:?]",
"at java.util.regex.Pattern$BranchConn.match(Pattern.java:4763) ~[?:?]",
"at java.util.regex.Pattern$BmpCharProperty.match(Pattern.java:4020) ~[?:?]",
"at java.util.regex.Pattern$Branch.match(Pattern.java:4800) ~[?:?]",
Sudachiプラグインを入れる前は同じメッセージを食わせても何もエラーが出ていなかったことから、Sudachiプラグインの何かが悪いということはすぐに分かりました。
ですが、「StackOverflow」?「java.util.regex」...?
これらのエラーに、当初は全く心当たりがありませんでした。
コーナーケース
調査を進めていくと、特定のメッセージを食わせると上のエラーを吐いて落ちることが分かりました。
そのメッセージが以下のものです。
確かに、如何にもコーナーケースっぽいメッセージですね。その感嘆符「!」の数、驚愕の21,828個。
何かが「StackOverflow」したのは納得できそうです。でも何が?
ちょっと正規表現のお話
先程のエラーに「java.util.regex」とありましたが、これを元に更に調査を進めていくと、以下の部分が原因であることが分かりました。
どうやら、Javaの正規表現では(!|?)*
といったalternationのgreedyなループは関数の再帰的な呼び出しでのマッチ処理に書き換わるようで、これが深すぎるとStackOverflowErrorを起こします。
より簡単なコードに還元すると、以下のようになります。
// "!" x 10,000
String s = String.join("", Collections.nCopies(10000, "!"));
// Bad: causes java.lang.StackOverflowError
Pattern p = Pattern.compile("(!|?)*");
Matcher m = p.matcher(s);
if (m.find()) {
System.out.println("Match found!");
}
今回は一文字のalternationだったので、素直にcharacter classを使うように書き換えました。
こうすることで、StackOverflowErrorは起こらなくなります。
// "!" x 10,000
String s = String.join("", Collections.nCopies(10000, "!"));
// Good
Pattern p = Pattern.compile("[!?]*");
Matcher m = p.matcher(s);
if (m.find()) {
System.out.println("Match found!");
}
ちなみに私が使っているエディタであるIntelliJ IDEAは、「一文字のalternationはcharacter classに書き換えろ」と怒ってくれます。偉いですね。
修正、そしてデプロイへ・・・
一文字のalternationをcharacter classを使うように書き換えたプルリクエストをSudachiに出しました。
比較的すぐにマージされたので嬉しいです。
2021年4月2日現在、この修正を含んだバージョンのリリースはまだされていないので、自分達で使う分は修正済みのソースをビルドして使っています。
近日、本番環境のtraQにもこの変更が入り、日本語検索ができるようになる予定です。お楽しみに!