mcpField
A
activerecord-tenanted

activerecord-tenanted

github.com/thinkoodle開発7165 回インストール8 週間前に更新
リポジトリ

activerecord-tenanted について

Basecamp の `activerecord-tenanted` gem を使い、Rails のテナントデータを個別の SQLite データベースに分離しつつ、ID やテナント情報をグローバルに保つ方法をまとめたスキルガイドです。シャード選択、リクエストのスコープ、ジョブ、マイグレーション、テストを扱います。

主な機能

  1. 01グローバルとテナントのレコードを分離
  2. 02テナントごとの SQLite データベース作成
  3. 03リクエストパスからテナントを設定
  4. 04ジョブ内でテナントスコープを再設定
  5. 05テナントDBの安全なマイグレーションとテスト

活用シーン

  1. 01パスベースの Rails マルチテナント設計
  2. 02IDレコードとワークスペースデータの分離
  3. 03バックグラウンドジョブからのテナントレコード操作
  4. 04アクティブなテナントが必要なコードのテスト

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/thinkoodle/rails-skills --skill activerecord-tenanted

インストール後、Claude Code で/activerecord-tenantedを入力して読み込まれたか確認します。

同梱ファイルreference.md

詳細

activerecord-tenanted(テナントごとに SQLite)

Basecamp の activerecord-tenanted gem を使い、テナントごとに SQLite ファイルを 1 つ用意します。ID、セッション、テナントのレコードはグローバルデータベースに保存します。製品データは storage/tenants/{env}/{tenant_key}/main.sqlite3 に保存します。

これは Herald / Cortex / Innkeeper のモデルです。以下とは異なります。

  • Nebula の acts_as_tenant — データベースは 1 つで、すべての行に workspace_id を設定します
  • Fizzy のパスベースのテナント管理 — データベースは 1 つで、Current.account と SCRIPT_NAME の URL プレフィックスを使います

アプリですでに acts_as_tenant や Fizzy 方式のアカウントスラッグを共有スキーマで使用している場合、この gem を導入しないでください。

基本方針

  1. ベースクラスは 2 つ、データベースも 2 つです。 GlobalRecord はテナントデータを参照しません。ApplicationRecord は ID を参照しません。
  2. テナントが指定されていない状態でテナントのクエリを実行しないでください。 with_tenant / Current.tenant= の外での User.find は、ジョブや rake タスクでもバグです。
  3. シャードは URL(または Current)で選択します。 gem のデフォルトのサブドメインリゾルバーはパスベースのアプリには適しません。無効にしてください。
  4. ジョブではテナントコンテキストを再設定します。 Active Job がシャードを自動的に引き継ぐことはありません。グローバル ID(アカウント/テナント/プロジェクト)を渡し、perform を with_tenant でラップしてください。

データの保存先

グローバル(GlobalRecord)テナント(ApplicationRecord)
Identity、Session、MagicLink実際の製品レコード
Tenant / AccountUser(Cortex/Innkeeper — テナント内のプロフィール)
Membership(Identity ↔ tenant)そのワークスペース内のその他すべて
JoinCode、API トークンActive Storage の blob(tenanted_rails_records が有効な場合)

Herald では、シャードが選択される前にスラッグ/API キーを利用できるよう、Project をグローバルに保持し、リリース/購読者をアカウントのテナントに保存します。Cortex/Innkeeper では、ほとんどの製品テーブルをテナントのデータベースに配置します。どちらでも問題ありません。どちらかを選び、一貫して運用してください。

ステップ 1:Gem と database.yml

# Gemfile
gem "activerecord-tenanted"
# config/database.yml — every environment
primary:
  adapter: sqlite3
  database: storage/tenants/<%= Rails.env %>/%{tenant}/main.sqlite3
  tenanted: true
  max_connection_pools: 50
global:
  adapter: sqlite3
  database: storage/<%= Rails.env %>.sqlite3
  migrations_paths: db/global_migrate
queue:
  adapter: sqlite3
  database: storage/<%= Rails.env %>_queue.sqlite3
  migrations_paths: db/queue_migrate
# cache + cable the same way

%{tenant} は gem のプレースホルダーです。primary の database_tasks: false は任意です(Herald では、Rails が単一の primary スキーマをダンプしようとしないように設定しています)。

マイグレーション:

  • db/global_migrate/ — Identity、Tenant/Account、Membership、Session。一度だけ実行します。
  • db/migrate/ — テナントのテーブル。gem がテナントごとに実行します。
bin/rails db:migrate          # tenanted
bin/rails db:migrate:global    # global — check the gem's task name in the app

ステップ 2:2 つの抽象クラス

# app/models/application_record.rb
class ApplicationRecord < ActiveRecord::Base
  primary_abstract_class
  tenanted
end

# app/models/global_record.rb
class GlobalRecord < ActiveRecord::Base
  self.abstract_class = true
  connects_to database: { writing: :global, reading: :global }
end

新しいモデルが人物、セッション、またはテナントのレコード自体であれば GlobalRecord を使います。ワークスペース内のデータであれば ApplicationRecord を使います。ここを間違えることが、最も多いバグです。

ステップ 3:サブドメインリゾルバーとデフォルトテナントを無効にする

パスベースのアプリ(すべての自社アプリ)では、gem のサブドメインリゾルバーを使用しないでください。

# config/initializers/tenanted.rb
Rails.application.configure do
  config.active_record_tenanted.tenant_resolver = ->(request) { nil }
  config.active_record_tenanted.default_tenant =
    Rails.env.local? ? "#{Rails.env}-tenant" : nil
  config.active_record_tenanted.tenanted_rails_records = true
  config.active_record_tenanted.connection_class = "ApplicationRecord"
end

本番環境では default_tenant を nil にしてください。 そうしないと、起動時に ARTENANT not set で失敗するか、さらに悪い場合、すべてのリクエストが残っている 1 つのシャードを共有します。ローカルでは、名前付きのデフォルトテナントを設定すると、コンソールやテストを起動できます。

tenanted_rails_records = true にすると、Active Storage / Action Text がキーにプレフィックスを付けてテナントのデータベースに保存されます。active-storage-multitenant スキルと組み合わせてください。

ステップ 4:Current でシャードを切り替える

class Current < ActiveSupport::CurrentAttributes
  attribute :identity, :session
  attribute :tenant  # or :account — pick one name and use it everywhere
  attribute :user

  def tenant=(tenant)
    super
    return if ActiveRecord::Base.shard_swapping_prohibited?

    ApplicationRecord.current_tenant =
      tenant ? tenant.external_id : ActiveRecord::Tenanted::Tenant::UNTENANTED_SENTINEL
  end
end

Herald では Current.account= と account.tenant_key("account-#{id}")を使います。Cortex/Innkeeper では Current.tenant= と tenant.external_id を使います。仕組みは同じです。

リクエスト終了時にリセットします(Cortex)。

resets do
  next if ActiveRecord::Base.shard_swapping_prohibited?
  ApplicationRecord.current_tenant = ActiveRecord::Tenanted::Tenant::UNTENANTED_SENTINEL
end

ステップ 5:URL からテナントを解決する

gem のリゾルバーではなく、コントローラーの concern で行います。

module TenantScoping
  extend ActiveSupport::Concern

  included do
    before_action :set_current_tenant
    before_action :set_current_user
  end

  def set_current_tenant
    record = Tenant.find_by(external_id: params[:tenant_external_id])
    Current.tenant = record if record
  end

  def set_current_user
    return unless Current.identity && Current.tenant
    Current.user = User.find_by(identity_id: Current.identity.id)
  end
end

ルートはテナントキー(/:tenant_external_id/...、または Account を読み込むプロジェクトスラッグ)の下にネストします。サインインやベンダーからの Webhook など、認証を必要としないページではこの処理をスキップし、テナントを必要とする処理だけをラップします。

Tenant/Account の作成時にテナントデータベースを作成します。

after_create :provision_tenant_database

def ensure_tenant_database!
  ApplicationRecord.create_tenant(tenant_key) unless database_exists?
rescue ActiveRecord::Tenanted::TenantExistsError
  # parallel tests / retries
end

ステップ 6:ジョブ、rake、コンソール

def perform(tenant_external_id, record_id)
  ApplicationRecord.with_tenant(tenant_external_id) do
    Current.tenant = Tenant.find_by(external_id: tenant_external_id)
    Record.find(record_id).do_work
  end
end

テナント対応の Active Record を GID としてシリアライズし、perform で読み込めると期待しないでください。ジョブはテナントなしで実行されます。グローバルなテナントキーとレコード ID を渡してください。

Rake/単発実行:

Tenant.find_each do |tenant|
  ApplicationRecord.with_tenant(tenant.external_id) do
    puts Project.count
  end
end

URL にテナントが含まれない公開インバウンド Webhook の場合は、テナントを順に処理する(Innkeeper)か、テナントを参照するグローバルトークンを検索してから with_tenant を使用してください。ジョブの最初に User を検索しないでください。

テスト

テナント対応モデルにアクセスするすべてのテストで、テナントを設定する必要があります。ヘルパーを使用してください。

def with_tenant(tenant = tenants(:acme), &block)
  ApplicationRecord.with_tenant(tenant.external_id, &block)
end

統合テストでは、TenantScoping が実行されるように、テナントプレフィックスを含む URL にアクセスしてください。Current.tenant をスタブしてから HTTP の結果を検証しないでください。シャードがリクエストと一致しなくなります。

アンチパターン

  1. すでにテナント対応のアプリで acts_as_tenant :workspace を使う — テナントシステムが二重になります。Nebula はもう一方のモデルです。混在させないでください。
  2. Identity または Session を ApplicationRecord に配置する — これらのテーブルはシャードの切り替え後も存続する必要があります。
  3. ジョブで with_tenant を使わずに User.find を実行する — 結果が暗黙に空になるか、TenantNotSet が発生します。
  4. サブドメインリゾルバーを有効にしたままにする — パスベースのアプリで誤ったテナントが解決されるか、テナントが解決されません。
  5. 本番環境で default_tenant を使う — すべてのプロセスで 1 つのデータベースファイルが共有されます。
  6. テナント DB で整数の PK を前提にする — テナント対応テーブルでは UUID を使用します。uuid-primary-keys を参照してください。
  7. 現在のテナントが設定されていない状態で blob を作成する — 「テナントなしでは Blob キーを生成できません。」with_tenant と Active Storage スキルを使用してください。

関連項目

  • Identity と User、メンバーシップ、参加コード:identity-membership
  • テナント対応の Active Storage/Lexxy:active-storage-multitenant
  • テナント内で実行されるアウトバウンド Webhook:outgoing-webhooks
  • パスワードレスサインイン(グローバル Identity):magic-link-auth

database.yml、Herald と Cortex の命名、シャード切り替え時の注意点については、reference.md を参照してください。

コメント