DIGGLE開発者ブログ

予実管理クラウドDIGGLEのプロダクトチームが、技術や製品開発について発信します

Rails+ApartmentにPostgreSQLのプロシージャを導入して多軸分析の集計速度を向上させた話 その5 ~apartmentへのPR編~

こんにちは。DIGGLEエンジニアのzakkyです。

遂に今回で最終回となります。レポート(多軸分析)の集計処理部分のパフォーマンス向上施策の第5弾です。

前回までの記事の紹介

diggle.engineer diggle.engineer diggle.engineer diggle.engineer

今回のお題

今回はapartmentに対してPRを出すに至ったあれやこれやをご紹介します。

書かないこと

以下の内容は含みませんので予めご了承ください

  • Rails、Apartmentの導入などの基礎的な部分
  • apartmentの説明

どれくらい速くなったのか

前回も記載しましたが、以下が今回改善した集計処理部分のLatency推移となります。 リリース後には低下していることが見て取れるかと思います。

大体3倍速程度には改善することができました。

赤線の辺りで今回の改善版のリリースを行っています

publicスキーマに対する参照があるプロシージャが動かない

ここでまた別の問題が発生しました。

以下のようなプロシージャがあるとします。(サンプルのSQLなので「User.allで取れるじゃん!」とは言わない約束です)

BEBIN
RETURN QUERY SELECT * FROM public.users;
END;

※前後のプロシージャ関連の定型文は省略しています

new_tenantテナントを作って、こちらのプロシージャを見ると…

BEBIN
RETURN QUERY SELECT * FROM new_tenant.users;
END;

と、publicであるべき箇所が、new_tenantとなってしまう問題が発生しました。

今回も、最終的な変更内容については結論に記載していますので、結果だけ知りたい方は最下部を参照ください。

原因を探る

またライブラリ(今度はApartment)を読む時間です。 偶然ですが、以前に余談として書いた部分にヒントがありました。

テナント作成時の流れを追う

重要な部分だけ抜き出してみました。 import_database_schemaはテナント新規作成時に呼び出され、その中のcopy_schema_migrationsでは、pg_dumpをした上でpatch_search_pathというfunctionを呼び出しているようです。

      def import_database_schema
        preserving_search_path do
          clone_pg_schema
          copy_schema_migrations
        end
      end

      def copy_schema_migrations
        pg_migrations_data = patch_search_path(pg_dump_schema_migrations_data)
        Apartment.connection.execute(pg_migrations_data)
      end

      def pg_dump_schema_migrations_data
        with_pg_env { `pg_dump -a --inserts -t #{default_tenant}.schema_migrations -t #{default_tenant}.ar_internal_metadata #{dbname}` }
      end

      def patch_search_path(sql)
        search_path = "SET search_path = \"#{current}\", #{default_tenant};"

        swap_schema_qualifier(sql)
          .split("\n")
          .select { |line| check_input_against_regexps(line, PSQL_DUMP_BLACKLISTED_STATEMENTS).empty? }
          .prepend(search_path)
          .join("\n")
      end

code link

patch_search_pathを追う

一体どんなことをしているのかと開けてみると、pg_dumpで取得した文字列に対してdefault_tenantに当たる部分をreplaceしていることが分かります。

      def patch_search_path(sql)
        search_path = "SET search_path = \"#{current}\", #{default_tenant};"

        swap_schema_qualifier(sql)
          .split("\n")
          .select { |line| check_input_against_regexps(line, PSQL_DUMP_BLACKLISTED_STATEMENTS).empty? }
          .prepend(search_path)
          .join("\n")
      end

      def swap_schema_qualifier(sql)
        sql.gsub(/#{default_tenant}\.\w*/) do |match|
          if Apartment.pg_excluded_names.any? { |name| match.include? name }
            match
          else
            match.gsub("#{default_tenant}.", %("#{current}".))
          end
        end
      end

code link

どうしたら良いか考えてみる

pg_dumpを行った結果を見てみると分かるのですが、create文にもschema名が記述されているため、単純にswap_schema_qualifierを行わないようにすることはできません。 かと言って、「プロシージャ内のロジックだけ置換を行わない」といった事は難しそうです。 プロシージャ内のtable名を動的に生成しEXECUTEで呼び出す方法もありますが、動的にSQL文を生成する分、性能劣化が発生するため今回は使えません。

と思いつつ、ソースを眺めていると、以下のようなコメントアウトされたロジックが目につきました。

      def pg_dump_schema
        # Skip excluded tables? :/
        # excluded_tables =
        #   collect_table_names(Apartment.excluded_models)
        #   .map! {|t| "-T #{t}"}
        #   .join(' ')

        # `pg_dump -s -x -O -n #{default_tenant} #{excluded_tables} #{dbname}`

        with_pg_env { `pg_dump -s -x -O -n #{default_tenant} #{dbname}` }
      end

code link

Apartmentの仕様をもう一度考える

Apartmentとしてどのように動くのが正しいのかを考えてみます

  • excluded_tablesに記載してあるテーブルについては、publicスキーマ上で管理したいテーブルである
  • publicスキーマ以外にもexcluded_tablesが存在するのは、差分が出ないようにするためで、必須ではない

上記であると考えると、コメント部分を復活させて微調整してあげれば解決しそうです。

解決案

以下のようにすれば解決する筈です。

  • テナント追加時に作成するスキーマにexcluded_tablesは存在する必要はないので、pg_dump時の対象から除く
  • swap_schema_qualifierで置換する際に、default_tenant + excluded_tablesの組み合わせだったときは置換させないようにする
    • 事前にpg_dumpの対象からexcluded_tablesに記載のテーブルは除外しているため、プロシージャ内のロジックでのみ出てきます

結論

モンキーパッチを書く

forkしたリポジトリを運用する方法もありますが、今後のバージョンアップに付いていくのが辛いのと、そこまで大きな改修ではないので、今回はモンキーパッチとします。

module PostgresqlSchemaFromSqlAdapterMonkeyPatch
  private

  def pg_dump_schema
    exclude_table = excluded_tables.map! { |t| "-T #{t}" }.join(' ')
    with_pg_env { `pg_dump -s -x -O -n #{default_tenant} #{dbname} #{exclude_table}` }
  end

  def swap_schema_qualifier(sql)
    sql.gsub(/#{default_tenant}\.\w*/) do |match|
      if Apartment.pg_excluded_names.any? { |name| match.include? name }
        match
      elsif excluded_tables.any?(match)
        match
      else
        match.gsub("#{default_tenant}.", %("#{current}".))
      end
    end
  end

  def excluded_tables
    Apartment.excluded_models.map do |m|
      m.constantize.table_name
    end
  end
end

require 'apartment/adapters/postgresql_adapter'
Apartment::Adapters::PostgresqlSchemaFromSqlAdapter.prepend(PostgresqlSchemaFromSqlAdapterMonkeyPatch)

PRを出してみる

今回はそこそこ修正量もあったので、せっかくなのでPull Requestを出してみようと思います。 かくいう私も初めてPull Requestを投げるのでドキドキしながら行ったのですが、以下を参考にしました。

  • Contributingの内容を確認する
    • 大前提ですのでしっかり読んでおきます
  • OSSへPull Requestを投げてみた各種サイト(「oss pull request」とかでググると色々出てきます)
    • 読みやすくまとめてくださっているサイトが色々ありますので、参考にさせていただきました
  • DeepL
    • 英語が得意ではないので、大活躍していただきました

Pull Requestの内容がニッチすぎるせいか、未だに何の反応も無いのが悲しい限りですが、OSSのライブラリに手を加えてPull Requestを投げるまでの一連の流れを体験できたので個人的に良い経験になったな。と、思っています。

最後に

全5回にわたってここまでお付き合いいただきありがとうございました。

次回からは、また別の題材で記事を書きますので(私ではない別のエンジニアになる予定)、ご期待ください。

We're hiring!

DIGGLEでは、必要に応じてライブラリのソースを読み解いて問題解決に挑む開発メンバーを募集しています!少しでも興味があれば、ぜひ下記採用サイトからエントリーください。

herp.careers

Meetyによるカジュアル面談も行っていますので、この記事の話をもっと聞きたい!という方がいらっしゃいましたら、お気軽にお声がけください。

meety.net