839の日記

業務とは関係ない趣味の話を書くブログです。

X-DEN物語

注意

この話はIIDX Score Tableというサービスを使っていないと非常に理解が難しい話です。*1 プログラムの話ですができるだけ簡略化して書いています。


前提条件として悪いX-DENと良いX-DENがいます。 悪いX-DENはXがアルファベットではなく特殊文字です。 良いX-DENは普通のアルファベットで構築されています。

また、スクリプトで取得したユーザは良いX-DEN、 CSVで取得したユーザは悪いX-DENで登録されています。

この状態のときにどのような分岐をするか考えてみましょう。

第一に、悪いX-DENを登録しているユーザを良いX-DENデータに変換する必要があります。 これは擬似的には以下のような処理で移行できます。

Score.find_all(name: 悪いX-DEN).update_all(name: 良いX-DEN)

しかしこれを実行するとエラーになってしまいました。 エラーの理由は既に「良いX-DENで登録されている」という内容です。

これはなぜ起きたのでしょうか? 答えとしてはデータに対してある制約を持たせていたためです。*2 その制約とは、「あるユーザが、あるバージョンで、ある譜面(SPN, SPH, SPA, DPN, DPH, DPA)に対して1つの記録しか持つことができない」というものです。*3

例えばあなた(Aさん)がRootageで冥SPAをハードしていた場合は1行に以下のように記録されます。

バージョン: Rootage, ユーザ: Aさん、 譜面: 冥SPA、ランプ: ハード

こういうデータ存在するときに

バージョン: Rootage, ユーザ: Aさん、 譜面: 冥SPA、ランプ: エクハ

といった新しいデータを「追加」することはできません。 既にあるデータを「更新」する必要があるのです。

状態として

バージョン: Rootage, ユーザ: Aさん、 譜面: 冥SPA、ランプ: ハード
バージョン: Rootage, ユーザ: Aさん、 譜面: 冥SPA、ランプ: エクハ

というデータ群は不整合とみなされるように制約をかけている、ということです。 元々のデータとしては以下のようになっていました。

バージョン: Rootage, ユーザ: Aさん、 譜面: 悪い-XDEN SPA、ランプ: ハード
バージョン: Rootage, ユーザ: Aさん、 譜面: 良い-XDEN SPA、ランプ: ハード

このデータの悪いX-DEN SPAを良いX-DEN SPAに更新しようとし…

バージョン: Rootage, ユーザ: Aさん、 譜面: 良い-XDEN SPA、ランプ: ハード
バージョン: Rootage, ユーザ: Aさん、 譜面: 良い-XDEN SPA、ランプ: ハード

「良いX-DEN SPAは既に登録されているデータです」と言われて弾かれてしまったというわけです。 このように書くとわかりやすいのですが、当初はそもそも悪いX-DENを登録している人が良いX-DENも登録しているかもしれない、という発想がありませんでした。*4 具体的には以下に該当するユーザでは上記のような状態になりえます。

スクリプトCSVの両方でスコアを送信したことがあるユーザ」

上記のようなユーザが存在する、ということを認知した今、データの移行方法を再考しなければいけません。 このようなユーザにどのような対処をするべきか? 最初に以下のようなアプローチを思いつきました。

  1. スクリプトCSVの両方でX-DENを登録しているユーザを抽出する
  2. スクリプトCSVでどちらが新しいスコアなのかを検証する
  3. スクリプトのほうが新しい場合、CSVのデータを消すだけで解決する
  4. CSVのほうが新しい場合、スクリプトの方で登録された楽曲名のほうに付け替える

これを行った結果、次に出たエラーは「移行先の良いX-DEN が見つかりません」でした。

最初に私は良い or 悪いX-DENを登録しているという定義にSPN, SPH, SPA, DPN, DPH, DPAのいずれかが1つでも登録されているかを用いていました。 その結果以下のような現象に遭遇しました。

「DPのX-DENではスクリプトで登録しているが、SPのX-DENはCSVで登録しているケース」

このようなケースでは、4.の付け替えをするときにSPN,SPH,SPA,DPN,DPH,DPAをまるごと付け替えようとしているので、SPの悪いX-DENを良いX-DENに付け替えようとした際に付け替え元が見つからない、といったことになります。*5

これを考慮すると、最初の良い or 悪いX-DENが登録されている定義を見直す必要があります。 プレイスタイルごとに分けて検討する必要があるわけです。

それを踏まえて再度ロジックを考えると以下のようになります。

  1. SPの良いX-DENと悪いX-DENを登録しているユーザを抽出する
  2. スクリプトCSVでどちらが新しいスコアなのかを検証する
  3. スクリプトのほうが新しい場合、CSVのデータを消すだけで解決する
  4. CSVのほうが新しい場合、スクリプトの方で登録された楽曲名のほうに付け替える
  5. DPにも1. から同じ操作を繰り返すようにする

くぅ~疲れましたw これにてX-DEN物語は完結です!*6 実は、X-DENが表記揺れしているのは前から認知していたのですがリプで困っている報告をされたのが移行のきっかけでした。 本当はこの記事のネタにするつもりはなかったのですが← ご厚意を無駄にするわけには行かないので流行りのネタで挑んでみた所存ですw 以下、表記ゆれブラザーズの達のみんなへのメッセジをどぞ

炎影「みんな、見てくれてありがとう ちょっと腹黒なところも見えちゃったけど・・・気にしないでね!」

火影「いやーありがと! 私のかわいさは二十分に伝わったかな?」

焱影「見てくれたのは嬉しいけどちょっと恥ずかしいわね・・・」

旋律のドグマ~Miserables~「見てくれありがとな! 正直、作中で言った私の気持ちは本当だよ!」

旋律のドグマ ~Misérables~「・・・ありがと」ファサ

では、

炎影、火影、焱影、旋律のドグマ~Miserables~、旋律のドグマ ~Misérables~、俺「皆さんありがとうございました!」

炎影、火影、焱影、旋律のドグマ~Miserables~、旋律のドグマ ~Misérables~「って、なんで俺くんが!? 改めまして、ありがとうございました!」*7

本当の本当に終わり*8

refs: dic.nicovideo.jp

蛇足

この後、X-DENだけ何故かスコア一覧に出てこないという問題に遭遇し、10分ほど頭を悩ませました。 スクリプトで送られてきたデータはLevel情報が取得できないのでLevel0という扱いで登録しているのですが、 Level0の曲は表示しない、というロジックを入れているためでした。 最後の最後までやってくれますねぇ。

あと、もちろんCSVで送ってくる悪いX-DENさんは送られてきたときに良いX-DENに更生させて登録するように変更しました。 そうしないと今まで書いた内容が無限ループしますからね!


おまけ

移行に使ったスクリプト

最初は本番サーバでちょちょっとコマンド打って終わりのつもりが思ったよりだいぶ根深かったので、手元にDBを落としてきて色々試行錯誤しました。 検証中はtransactionで囲ってました(いけたと思ったら落ちたりすることが多すぎて中途半端に移行されるとDBの入れ直しが面倒なので)。

%w[SP DP].each do |style|
  good_x_den = Sheet.find(良いX-DEN).music_scores.where(play_type_status: style).order(:difficulty_type_status).pluck(:id)
  bad_x_den = Sheet.find(悪い-XDEN).music_scores.where(play_type_status: style).order(:difficulty_type_status).pluck(:id)

  mapping = {}
  bad_x_den.each_with_index do |bad, index|
    mapping[bad] = good_x_den[index]
  end

  User.accepted.find_each do |user|
    goods = user.scores.where(music_score_id: good_x_den)
    bads = user.scores.where(music_score_id: bad_x_den)

    # NOTE: 両方存在しているユーザは移行する
    # 悪い方が良い方より古い場合は古い方を削除して終わり
    # 悪い方が良い方より新しい場合は古い方の要素を取り出して古い方を削除したあと、新しい方に更新する
    if goods.present? && bads.present?
      if goods.order(updated_at: :desc).first.updated_at > bads.order(updated_at: :desc).first.updated_at
        bads.destroy_all
      else
        bads.each do |bad|
          good = goods.find_by!(music_score_id: mapping[bad.music_score_id])
          good_id = good.id
          good.attributes = bad.attributes
          good.id = good_id
          good.music_score_id = mapping[bad.music_score_id]
          good.save!
          bad.destroy!
        end
      end
    end
  end
  # NOTE: ここに来るということは両方存在するわけではなく悪いX-DENしか存在しないユーザのはず
  # そういった人は無条件に良いX-DENに転生させる
  bad_x_den.each do |bad|
    Score.where(music_score_id: bad).update_all(music_score_id: mapping[bad])
  end
end

感動の結果*9

irb(main):007:0> Score.where(music_score_id: Sheet.find(悪いX-DEN).music_scores.pluck(:id)).count
=> 0

雑にupdate_allとかかましてますが、業務ならちゃんとデータが移行できているか検証する必要があります。*10 なんでこんなことにそんなにお金がかかるの?というシステムの改築の裏にはこういう話が沢山隠れていると思います。

*1:ISTを使っていなくてもiidxを知っていれば何とかなるかも

*2:後述していますがこの制約は鬱陶しいものではなく必要なものです

*3:この制約がないとユーザにスコア一覧を見せるときに重複して見せざるを得ないケースが出てきます。表示する側で絞ろうとするとまともにページネーションもできません

*4:あー、こういうケースもあるのかぁという気持ちになりました

*5:これ、遭遇したとき最初はまじで意味がわからなくてどういうこと??って5分ぐらい悩んでました

*6:やたらエラーで詰まっていたように見えるかもしれませんが、これは期待している挙動です。実際変なデータができてしまう可能性はたびたびありましたがすべて制約のおかげで回避しています。人間の操作は簡単に信じてはいけない。

*7:過去にIST関係で遭遇したことがある表記ゆれたちですが、参考表を含めるともっとたくさんあります。明らかな文字化けはコナミに報告すると直してくれます。言われないと気づかないよ本当、なのでみんな報告してください

*8:表記ゆれに苦しんだことはたびたびありますが、ここまで苦しめられたのは今回が初めてです

*9:感動の結果に辿り着くまでに2時間ぐらいかかりました

*10:ISTは5000万レコードぐらいあるので、ちゃんと検証すると死ぬなと思ってやってません。該当しそうなやつをちゃんと絞ればもっと少ないけど無料サービスだし