Aurora/RDS用プロダクション→ステージング レプリケーションツールを書いた

github.com

これは何?

Aurora/RDSでプロダクション→ステージングのデータのレプリケーションを行うツールです。

開発環境のデータをできるだけ本番に近づける - クックパッド開発者ブログ」という記事があって、同じことをAurora/RDSで行うために作りました。

Aurora/RDSでの問題

mysql.rds_set_external_masterを使う場合

  • レプリケーションフィルターが使えない
    • REPLICATE_WILD_IGNORE_TABLEとかが使えないので、ステージングに流したくないテーブルの情報もレプリケーションされます
  • エラーがスキップしにくい
    • mysql.rds_skip_repl_errorはあるんですがslave-skip-errorsの用にまとめて複数のエラーのスキップができないため、event schedulerで毎分呼び出す、みたいな苦しい運用に

DMSを使う場合

binrpt

MySQLのレプリカサーバのようにrowフォーマットのbinlogを読み込んで、SQL(INSERT・UPDATE・DELETE)に変換するか、流れてきたDDLをレプリカに対して実行します。

特徴

  • テーブルのフィルタリングができる
  • エラーは無視
  • 接続が切れたら再接続

ラフにレプリケーションをできるようにしてるので、逆に厳密なレプリケーションには向かないです。

misc: go-mysqlがすごい

binlogを読み込むのにgo-mysqlを使っています。

github.com

googleabilityが悪くて「go-mysql」で検索してするとgo-sql-driver/mysqlが出てきたりするんですが

  • MySQLのクライアントライブラリ
  • binlogを継続的に読み込む(go-mysql-elasticsearchで使われているようです)
  • サーバとしてクライアントを受け付けられる

と、ニッチな気がする方向で強力でした。

MySQL Binlog APIのライブラリって、まだMySQL Labsからダウンロードできるんでしたっけ…? MySQL Binlog APIはベータ版であまり完成度が高くなく、実装がめんどくさかった(日本語まわりでバグもあった)記憶があって「binlogで自前レプリケーションはつらい」という印象だったんですが、go-mysqlはかなりサクッと使えて、わりあい頑丈なので(再接続なども実装済み)驚いてます

追記

go-mysqlMySQLクライアントとしての動作、メモリまわりが怪しい… あと https://github.com/siddontang/go-mysql/pull/466 これバグってないかな

SpringOnion: Railsの遅そうなEXPLAINをログ出力するgem

SpringOnionというRailsの遅そうなEXPLAINをログ出力するgemを作りました。

github.com

某kamopoさんのMySQLCasualLog.pmの移植?です。

cf. ふつうのWeb開発者のためのクエリチューニング

以前の移植とは異なって、Arproxyを必要とせず、単体で動きます。

何年前の話だよといわれそうですが、未だにスロークエリと戦っていたりするので…

Rails 3以前に搭載されていた、遅かったクエリを自動的にEXPLAINする機能とは異なり、フィルタで選別されたすべてのクエリに対してEXPLAINを実行し、まずそうなキーワードが出現したらログに出力します。

また、EXPLAINの実行先は開発用のDBだけでなく任意のDBに向けられます(例: ステージング環境のDBなど)。

使い方

GemfileにSpringOnionを追加して

group :development, :test do
  gem 'spring_onion'
end

enviroments/*.rbinitializersにSpringOnionのEXPLAIN実行先DB情報を記述します。

if Rails.env.development? || Rails.env.test?
  # EXPLAINを実行するDB
  SpringOnion.connection = Mysql2::Client.new(host: 'my-host', ...)

  # ActiveRecordの接続先と同じにする場合は↓な感じで
  #SpringOnion.connection = ActiveRecord::Base.connection.raw_connection
end

SpringOnion.enabled = trueにするかSPRING_ONION_ENABLED=1で、遅そうなEPLAINがログ出力されるようになります。

実行例

#!/usr/bin/env ruby
require 'active_record'
require 'spring_onion'

ActiveRecord::Base.establish_connection(
  adapter:  'mysql2',
  host: 'db',
  username: 'root',
  database: 'sakila'
)

SpringOnion.enabled = true # or `SPRING_ONION_ENABLED=1`
SpringOnion.connection = ActiveRecord::Base.connection.raw_connection
SpringOnion.source_filter_re = //

class Actor < ActiveRecord::Base
  self.table_name = 'actor'
  self.primary_key = 'actor_id'
end

Actor.all.to_a
root@d786c737d8a4:/mnt# bundle exec ./test.rb

SpringOnion   INFO  2020-07-19 03:19:05 +0000   {"sql":"SELECT `actor`.* FROM `actor`","explain":[{"line":1,"select_type":"SIMPLE","table":"actor","partitions":null,"type":"ALL","possible_keys":null,"key":null,"key_len":null,"ref":null,"rows":16,"filtered":100.0,"Extra":null}],"warnings":{"line 1":["slow_type"]},"backtrace":["/mnt/lib/spring_onion/explainer.rb:6:in `execute'","/mnt/test.rb:21:in `<top (required)>'"]}

SPRING_ONION_JSON_PRETTY=1で見やすく。

root@d786c737d8a4:/mnt# SPRING_ONION_JSON_PRETTY=1 bundle exec ./test.rb

SpringOnion   INFO  2020-07-19 03:19:43 +0000   {
  "sql": "SELECT `actor`.* FROM `actor`",
  "explain": [
    {
      "line": 1,
      "select_type": "SIMPLE",
      "table": "actor",
      "partitions": null,
      "type": "ALL",
      "possible_keys": null,
      "key": null,
      "key_len": null,
      "ref": null,
      "rows": 16,
      "filtered": 100.0,
      "Extra": null
    }
  ],
  "warnings": {
    "line 1": [
      "slow_type"
    ]
  },
  "backtrace": [
    "/mnt/lib/spring_onion/explainer.rb:6:in `execute'",
    "/mnt/test.rb:21:in `<top (required)>'"
  ]
}

上記の場合、"type": "ALL"が引っかかってwarningが出ています。

warning

https://github.com/winebarrel/spring_onion/blob/d659b2ca4ef2fda68a08179c9a3c8323299f604c/lib/spring_onion/config.rb#L4-L26

warningにはMySQLCasualLog.pmと同じルールを設定していますが、新しく追加することも可能です。

例えば、rowsが10,000行を超えていたら、warningするなど。

SpringOnion.warnings[:too_many_rows] = lambda do |exp|
  exp['rows'] > 10000
end

フィルタ

SQLとbacktraceのソースコードのパスでフィルタリング可能です。

たとえばSPRING_ONION_SQL_FILTER_RE=actor bundle exec ./test.rbと実行すると、actorが含まれるクエリだけEXPLAINします。

また、SPRING_ONION_SOURCE_FILTER_RE=my_path bundle exec ./test.rbと実行すると、パスにmy_pathが含まれるソースコード経由で実行されたクエリだけEXPLAINします。(デフォルトは/app/

その他

ログ出力先は標準出力がデフォルトですが、ファイルなどに変更可能です。

SpringOnion.logger = Logger.new('/foo/bar/zoo.log')

CSVとかTSVをJSON LinesにするツールをGoとRustで書いた

CSVとかTSVをJSON LinesにするツールをGoとRustで書いた。

Go版

github.com

$ printf 'foo,bar\nbar,zoo' | xjsonl -keys a,b
{"a":"foo","b":"bar"}
{"a":"bar","b":"zoo"}

Rust版

github.com

$ printf 'foo,bar\nbar,zoo' | xjr -k a,b
{"a":"foo","b":"bar"}
{"a":"bar","b":"zoo"}

モチベーションとか

qrnを作った関係でテキストをJSON Linesに変換するツールが欲しくなったのだが、あまり上記のようなツールが見つからず、joで頑張るのもめんどくさい、ということで自作した。

かなり小さいツールなので、Rust学習のためにポートするにはちょうどいいかと考えてRust版も作成。

Rust入門

The Rust Programming LanguageRust By Exampleを読んでいたのだけれど、いまいち頭に入らず。 最終的に「プログラミング言語Rust入門」という書籍をKindleで購入。

プログラミング言語Rust入門

プログラミング言語Rust入門

全部がカバーされているわけではないけれど(マクロの書き方とかアトリビュートの詳細とかジェネリクスの細かいところなどはなかった)、足がかりとしてはよい本だったと思う。

Rust版を作りながら思ったこと

  • unwrapまわりが最初わかりにくかった
    • https://qiita.com/nirasan/items/321e7cc42e0e0f238254 がとても参考になった
    • Goから入った人間としては「そんなにパニックしていいのか?」と思ったが、log.Fatalfを使ってたりするので、同じなのかも。unwrapよりはexpectでわかりやすいメッセージを表示した方が良さそうだけど
  • dbg!マクロは最初の方で知りたかった…
    • 参照にしないとたまにコンパイルエラーになったりするあたり、新鮮味を感じた
  • strとStringがあるのがややめんどくさい
    • とりあえずstructに併せてStringを標準的に使うようにしたけれど、正しいのかわからない
      • 引数は&strにしてみた
    • Stringのマクロかリテラルが欲しい
  • ファイル分けの標準がいまいちよくわからなかったので、Goっぽくはしてみた
    • モジュールまわりは解説を読むよりは https://github.com/shadowmint/rust-starter が参考になった
    • main.rsとlib.rsを共存させるよりはmain.rs内でmod hogehoge;と書いてしまった方が、CLIを作るときは楽なんじゃないかなーと思ったけれど、そういう例は見つからず…
    • →素直にlib.rsを書け、という気になってきた
  • ジェネリクスに慣れていないせいで、最初「キャストの方法は?」と探しまくった
  • クロージャがやや使いにくい気がした。定義ごとに個別の型になっているせい?なんとなく、引数で受け取るときはfn()使うよりもFn()を使っておいた方が無難な気がした
    • Golangのノリで、関数を返す高階関数を書こうと思ったけれど、めんどくさい感じになったので諦め。結局、関数ポインタを多めに使うようにした
    • クロージャに限らず、戻り値でのジェネリクスは鬼門感がある
  • テストはモジュール内で定義されていたほうがきれいな気がしたので https://github.com/winebarrel/xjr/tree/master/src/xjr のようにしてみたが、いいのか悪いのかわからず
    • mod.rsに実装書くのはアリなのかな?
  • ロスコンパイルはcrossでサクッとできた
  • splitにややはまった
    • "".split(",") -> ""
    • Rubyが特殊な仕様なのかも
  • trim_rightがdeprecatedでこだわりを感じるなど
  • Goに比べると実行ファイルがとても小さいのでよかった
  • 所有権については難しそうなイメージの割にほとんど考えることはなかった
  • ネストが深くなりがちなのはmatchのせいだろうか?
    • xjrは見直してみるとなんかGoっぽいかんじになってしまった

その他、Rustで参考にしたサイト

Rust所感

  • 個人で書いている分にはとても楽しい。よい言い方ではないけれど、パズル的な楽しみがある
  • プロジェクトで採用するには結構決断力がいりそう。慣れているか高スキルのメンバーが多くない限り、Golang選びそう
    • Goは手の筋力が必要だけど、Rustは脳の筋力が必要な感じ
  • Golangはランタイムの豪華さがやはり最大のメリットだなーと思った。ただ、今回は並行プログラミングまわりを全然比べられていないので、その辺は別途深追い予定
    • 関係ないけど、vlangはランタイムをがんばらないとGoほどのメリットは感じられなさそうな気がした

qrnというDBベンチマークツールを作った

qrnというDBベンチマークツールを作りました。

github.com

これは何?

羅列されたクエリを実行するだけのDBベンチマークツールです。 今のところMySQLにしか対応していませんが、PostgreSQLへの対応はそんなに難しくないと考えています。

羅列したクエリを実行するだけなので、クエリのログ(MySQLならgeneral log)をほぼそのままテストデータにすることができます。 逆に同じクエリを異なるパラメーターで実行するようなことはできないので、そういうむきであればJdbcRunnerなどを使った方がいいと思います。

Installation

https://github.com/winebarrel/qrn/releases から最新版をダウンロードしてください。

Usage

$ echo '{"query":"select 1"}' >> data.jsonl
$ echo '{"query":"select 2"}' >> data.jsonl
$ echo '{"query":"select 3"}' >> data.jsonl
$ qrn -data data.jsonl -dsn root:@/ -nagents 4 -rate 5 -time 10 -histogram
/ run 184 queries (20 qps)

          57µs - 115µs -
         115µs - 173µs -
         173µs - 231µs ---
         231µs - 289µs ------------
         289µs - 346µs ----------
         346µs - 404µs ------------------------------------------
         404µs - 462µs --------------------------------------------------------
         462µs - 520µs ------------------------------
         520µs - 760µs ----------

{
  "Started": "2020-05-13T11:18:14.224848+09:00",
   ...
}

主要なオプション

  • -dsn DSN
  • -data テストデータ。JSON Lines形式
  • -nagents 並列数。1 agentが1コネクション・1 goroutineで実行
  • -rate 1 agentが1秒に実行するクエリ数の上限。たとえば2 agentでrate 5を指定すると、期待されるQPSは10QPS
  • -time テストの実行時間(秒)

テストデータ

上記の通りテストデータはJSON Linesです。 デフォルトで"query"キーのクエリを実行しますが、-keyオプションで変更できます。 クエリ以外のキーは無視します。

テストデータはメモリにロードしないので、大きめのデータでもクライアント側のメモリを圧迫することはないと思います。 逆に、10万qpsとか高いスループットが必要な場合、それが足を引っ張る…かも(そこまで試せていないです)。

MySQLの場合、general logをJSON Linesに変換する必要があるので、以下のような変換ツールを作って、logrotateのタイミングでjsonlに変換するようにしています。

/var/log/general.log {
   # ...
    postrotate
        # ...
       /usr/local/bin/mysql-general-log-parser /var/log/general.log-$(date +%Y%m%d%H) | gzip > /var/log/general.log-$(date +%Y%m%d%H).jsonl.gz'
    endscript
}
#!/usr/bin/env ruby
require 'json'

module MysqlGeneralLogParser
  HEADER_REGEXP = /\A(?<time>\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+[A-Z]+)\s+(?<id>\d+)\s+(?<command>\w+)\b(?<argument>.*)\z/m

  Chunk = Struct.new('Chunk', :time, :id, :command, :argument)

  def parse(io:)
    chunk = nil

    io.each_line do |line|
      if HEADER_REGEXP =~ line
        yield(chunk) if chunk
        chunk = Chunk.new
        chunk.time = Regexp.last_match[:time]
        chunk.id = Regexp.last_match[:id]
        chunk.command = Regexp.last_match[:command]
        chunk.argument = Regexp.last_match[:argument]
      elsif chunk
        chunk[:argument] << line
      end
    end

    yield(chunk) if chunk
  end
  module_function :parse
end

def main
  MysqlGeneralLogParser.parse(io: ARGF) do |chunk|
    puts chunk.to_h.to_json
  end
end

main

結果の出力

テスト結果はJSONで標準出力に出力されます。

{
  "Started": "2020-05-13T11:18:14.224848+09:00",
  "Finished": "2020-05-13T11:18:24.559912+09:00",
  "Elapsed": 10,
  "Queries": 189,
  "NAgent": 4,
  "Rate": 5,
  "QPS": 18.287694303306097,
  "ExpectedQPS": 20,
  "Response": {
    "Time": {
      "Cumulative": "78.389862ms",
      "HMean": "392.47µs",
      "Avg": "414.761µs",
      "P50": "418.565µs",
      "P75": "462.099µs",
      "P95": "532.099µs",
      "P99": "735.68µs",
      "P999": "760.585µs",
      "Long5p": "632.823µs",
      "Short5p": "218.38µs",
      "Max": "760.585µs",
      "Min": "182.384µs",
      "Range": "578.201µs",
      "StdDev": "90.961µs"
    },
    "Rate": {
      "Second": 2411.0260584461803
    },
    "Samples": 189,
    "Count": 189,
    "Histogram": [
      {
        "57µs - 115µs": 1
      },
      {
        "115µs - 173µs": 1
      },
      {
        "173µs - 231µs": 4
      },
      {
        "231µs - 289µs": 14
      },
      {
        "289µs - 346µs": 12
      },
      {
        "346µs - 404µs": 48
      },
      {
        "404µs - 462µs": 63
      },
      {
        "462µs - 520µs": 34
      },
      {
        "520µs - 760µs": 12
      }
    ]
  }
}

よく見そうな値は

  • QPS: クライアント側でのスループット
  • ExpectedQPS: agent数×rateで算出した期待されるQPS。QPSとExpectedQPS乖離が大きいほどキャパオーバーが大きい(と思われる)
  • Response: 実行した全クエリの応答時間の統計値です。

JSON以外のメッセージについては標準エラー出力に出力されます。

-histogramオプションをつけるとヒストグラムが出力されます

          57µs - 115µs -
         115µs - 173µs -
         173µs - 231µs ---
         231µs - 289µs ------------
         289µs - 346µs ----------
         346µs - 404µs ------------------------------------------
         404µs - 462µs --------------------------------------------------------
         462µs - 520µs ------------------------------
         520µs - 760µs ----------

-htmlオプションをつけると、ヒストグラムのHTMLファイルが出力されます。

f:id:winebarrel:20200515140956p:plain

このへんは https://github.com/jamiealquiza/tachymeter の機能ですね。

モチベーションとか

とあるサービスで使われているDBのベンチマークを取ろうとしたとき、人間がテストデータを書くと、データによって性能の偏りが大きく「サービスで使われているDBのワークロードを再現するのは難しい」「general logをそのままテストデータに使えないかな」と思っていたのと、ほかのベンチマークツールを使っているといろいろいじりたいことが多くて(出力結果など)「自前でベンチマークツールを作っておくと便利そう」と思ったあたりがモチベーションです。

misc

  • -rateオプションは身の回りで使った感じではまあまあの精度でqpsを制限できているのですが、もう少し厳密にできればいいんですが、逆にそこまで必要かなとも v1.2.0から、まあまあしっかりするようになりました

  • テストデータはファイル読み込みなのでシャッフルが難しく、いまのところagentごとに読み込み開始位置をランダムにしているだけです。とくに問題はないのですが、もう少し賢くしたいです

追記

スクリプトでのデータ記述に対応しました

https://github.com/winebarrel/qrn#use-script-as-data

$ cat data.js
for (var i = 0; i < 10; i++) {
  query("select " + i);
}

$ qrn  -script data.js -dsn root:@/ -nagents 8 -time 15 -rate 5

追記2

PostgreSQLに対応しました

追記3

general.logをパースしてjsonlにするやつを作りました

github.com

SendGridのv3 Mail Send APIのRuby Clientを書いた

年末で少し時間があったので、書こう書こうと思っていたSendGridのv3 Mail Sendを書きました。

V3 Mail Send API Overview - SendGrid Documentation | SendGrid

github.com

READMEのUsageを見ればわかるとおり、ほぼAPIに即した薄いクライアントです。

とりあえず、現状で公式クライアントは利用しにくいので、自作した感じです。 非公式とはいっても公式がスキーマを公開しているので、rakeタスクで公式のスキーマを取得して、メール送信ポストリクエストのボディーのバリデーションをJSON Schemaで行うようにしています。

require 'kani_laser'

client = KaniLaser::Client.new(api_key: 'ZAPZAPZAP')

# see https://sendgrid.com/docs/API_Reference/Web_API_v3/Mail/index.html
client.send_mail(
  personalizations: [
    {
      to: [
        {
          email: 'john@example.com'
        }
      ],
      subject: 'Hello, World!'
    }
  ],
  from: {
    email: 'from_address@example.com'
  },
  content: [
    {
      type: 'text/plain',
      value: 'Hello, World!'
    }
  ]
)

で、公式クライアント

SendGrid公式のRubyクライアントは、あまりインターフェースがよいとは思えなくて

require 'sendgrid-ruby'
include SendGrid

from = SendGrid::Email.new(email: 'test@example.com')
to = SendGrid::Email.new(email: 'test@example.com')
subject = 'Sending with Twilio SendGrid is Fun'
content = SendGrid::Content.new(type: 'text/plain', value: 'and easy to do anywhere, even with Ruby')
mail = SendGrid::Mail.new(from, subject, to, content)

sg = SendGrid::API.new(api_key: ENV['SENDGRID_API_KEY'])
response = sg.client.mail._('send').post(request_body: mail.to_json)
puts response.status_code
puts response.body
puts response.parsed_body
puts response.headers
  • いくつかクラスを利用しているけれどすべてJSON生成のヘルパーで必須ではなく冗長
  • 送信用のメソッドが#_で、ちょっとわかりにくすぎると思う…(メタプログラミングを避けた? あー、sendメソッドが使いにくかったせいか)
  • method_missingを使ったかなり薄いラッパーでメソッドやパラメータのチェックが弱い。ヘルパークラスも空白などを除去しているだけでチェックの役に立っていない
  • 気になる書き方がいくつか(固定文字列のJSONをわざわざパースしてHashにしている・必須引数に空白のデフォルト値を設定している)

といった感じで、うーん…とクビをかしげる状態になっています。 v1からv2への変更で大幅に修正が入っていて、なぜこのようなわかりにくいインターフェースになったのか、ぜんぜん経緯を理解できていないです。v3 APIへの対応リソースが足りなかったんだろうか?

一方でOpen API specificationも公開していて、今回私の書いたKaniLaserはそこからschemaをとってきてリクエストパラメーターのJSONJSON Schemaでパリデーションするようにしています。

リクエストパラメーターのバリデーションができれば、インターフェースはシンプルになりそうですが、公式クライアントはなんか頑張っている?せいで煩雑です。なぜ

API定義はしっかりしているように見えるので、そこからどうとでもクライアントは生成できると思うのですが、なんでこんな状況になっているのかよくわからない…

わからない。全くわからない。


公式がちょちょいとやれば、メタ情報から各種言語のクライアントを自動生成できそうだけれど、なぜやってないんだろう? oas.jsonからクライアントを自動生成できそうだけど。 やっぱりリソースの問題かなぁ、Ruby優先度低いのかなぁ…うーん…

蛇足

https://sendgrid.api-docs.io/ を参照すればちょちょいのちょい…と思っていたんですが、oas.jsondefinitions、足りなくないですかね。email_objectとか。

最終的に https://github.com/sendgrid/sendgrid-oai/ のマスターをスキーマのソースにするようにしたんだけど、これ、正しいのかなぁ

カニレーザー

dic.pixiv.net

ridgepole: v0.7.6でignoreという属性をつけられるようにした

ridgeporeのv0.7.6でignoreという属性をカラム・インデックス・FKにつけられるようにした。

github.com


あんまり周知していなかったせいで、全然知られていないが、ridgepoleでもexecuteがつかえる。

https://github.com/winebarrel/ridgepole#execute

create_table "authors", force: :cascade do |t|
  t.string "name", null: false
end

create_table "books", force: :cascade do |t|
  t.string  "title",     null: false
  t.integer "author_id", null: false
end

add_index "books", ["author_id"], name: "idx_author_id", using: :btree

execute("ALTER TABLE books ADD CONSTRAINT fk_author FOREIGN KEY (author_id) REFERENCES authors (id)") do |c|
  # Execute SQL only if there is no foreign key
  c.raw_connection.query(<<-SQL).each.length.zero?
    SELECT 1 FROM information_schema.key_column_usage
     WHERE TABLE_SCHEMA = 'bookshelf'
       AND CONSTRAINT_NAME = 'fk_author' LIMIT 1
  SQL
end

冪等性を担保するために少しだけ拡張していて、ブロック内の返り値がtrueの場合のみ、executeを実行するようにしている。 ブロックにはコネクションを渡しているので、存在チェックをすることで重複してexecuteが実行されないようにすることができる。

…が、たとえばN-gramのフルテキストインデックスのように、Railsが対応していないカラムやインデックスをexecuteで作成しようとするとめんどくさいことになる。

例えば

create_table "books", id: false, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8", force: :cascade do |t|
  t.string "title", null: false
  t.index ["title"], name: "ft_index", type: :fulltext
end

execute("CREATE FULLTEXT INDEX ft_index ON books(title) WITH PARSER ngram") do |c|
  rows = c.raw_connection.query('SHOW INDEX FROM books', as: :hash)
  !rows.map{|r| r.fetch('Key_name') }.include?('ft_index')
end

のような感じでインデックスを貼ろうと思うと、先に「N-gramではないインデックス」が作成されてしまって、「N-gramのインデックス」はexecuteでは作成されなくなる。

executeのブロック内のクエリで頑張って「N-gramではない場合は再作成」というようにすれば、「N-gramのインデックス」を貼ることはできるが、流石にめんどくさいので、カラムやインデックスにignoreという属性をもたせることにした。

ignoreを使うとこんな感じになる

# -*- mode: ruby -*-
# vi: set ft=ruby :
create_table "books", id: false, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8", force: :cascade do |t|
  t.string "title", null: false
  t.index ["title"], name: "ft_index", type: :fulltext, ignore: true
end

execute("CREATE FULLTEXT INDEX ft_index ON books(title) WITH PARSER ngram") do |c|
  rows = c.raw_connection.query('SHOW INDEX FROM books', as: :hash)
  !rows.map{|r| r.fetch('Key_name') }.include?('ft_index')
end

…先ほどとほぼ同じだけれど、ignore: trueがついたカラム・インデックス・FKについては、ridgepoleは無視、全く変更に関与しないようにしている(つもり)。 なので、このスキーマ定義はRailsでは対応していない「N-gramのインデックス」を冪等性を保ったまま作成することができる(はず)。

テストが通ったらリリースする予定。