Skip to content

Commit ee285ea

Browse files
committed
Add tenant shard migration system with registry and migrator service
1 parent 32e449f commit ee285ea

File tree

5 files changed

+257
-26
lines changed

5 files changed

+257
-26
lines changed

app/lib/pwb/shard_registry.rb

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# frozen_string_literal: true
2+
3+
module Pwb
4+
module ShardRegistry
5+
LOGICAL_TO_PHYSICAL = {
6+
default: :primary,
7+
shard_1: :tenant_shard_1,
8+
shard_2: :tenant_shard_2,
9+
demo: :demo_shard
10+
}.freeze
11+
12+
module_function
13+
14+
def logical_shards
15+
LOGICAL_TO_PHYSICAL.keys
16+
end
17+
18+
def physical_name(logical)
19+
LOGICAL_TO_PHYSICAL[logical.to_sym]
20+
end
21+
22+
def configured?(logical)
23+
physical = physical_name(logical)
24+
return false unless physical
25+
26+
configs.any? { |config| config.name.to_sym == physical }
27+
end
28+
29+
def configs
30+
ActiveRecord::Base.configurations.configs_for(env_name: Rails.env)
31+
end
32+
33+
def describe_shard(logical)
34+
config = configs.find { |c| c.name.to_sym == physical_name(logical) }
35+
return { name: logical, configured: false } unless config
36+
37+
{ name: logical, configured: true, database: config.database, host: config.host }
38+
end
39+
end
40+
end
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
# frozen_string_literal: true
2+
3+
module Pwb
4+
class TenantShardMigrator
5+
class MigrationError < StandardError; end
6+
7+
BATCH_SIZE = ENV.fetch('PWB_TENANT_MIGRATION_BATCH', 500).to_i
8+
9+
attr_reader :website, :target_shard, :logger
10+
11+
def initialize(website:, target_shard:, logger: Rails.logger, dry_run: false)
12+
@website = website
13+
@target_shard = normalize_shard(target_shard)
14+
@logger = logger
15+
@dry_run = dry_run
16+
end
17+
18+
def call
19+
validate_target!
20+
return if @dry_run
21+
22+
ActsAsTenant.without_tenant do
23+
website.with_lock do
24+
migrate_records!
25+
website.update!(shard_name: target_shard.to_s)
26+
end
27+
end
28+
end
29+
30+
private
31+
32+
def migrate_records!
33+
source = website.database_shard
34+
logger.info("[TenantShardMigrator] Moving website ##{website.id} from #{source}#{target_shard}")
35+
36+
tenant_table_names.each do |table_name|
37+
migrated = migrate_table(table_name, source: source, target: target_shard)
38+
logger.info("[TenantShardMigrator] #{table_name}: migrated #{migrated} rows") if migrated.positive?
39+
end
40+
end
41+
42+
def migrate_table(table_name, source:, target:)
43+
total = 0
44+
45+
loop do
46+
rows = fetch_rows(table_name, source)
47+
break if rows.empty?
48+
49+
ids = rows.map { |row| row['id'] }
50+
ensure_no_conflicts!(table_name, ids, target: target)
51+
insert_rows(table_name, rows, target)
52+
delete_rows(table_name, ids, source)
53+
total += rows.size
54+
end
55+
56+
total
57+
end
58+
59+
def fetch_rows(table_name, shard)
60+
with_connection(shard) do |connection|
61+
connection.select_all(<<~SQL, 'TenantShardMigrator').to_a
62+
SELECT *
63+
FROM #{connection.quote_table_name(table_name)}
64+
WHERE website_id = #{connection.quote(website.id)}
65+
ORDER BY id ASC
66+
LIMIT #{BATCH_SIZE}
67+
SQL
68+
end
69+
end
70+
71+
def insert_rows(table_name, rows, shard)
72+
return if rows.empty?
73+
74+
with_connection(shard) do |connection|
75+
connection.insert_all(rows, table_name)
76+
end
77+
end
78+
79+
def delete_rows(table_name, ids, shard)
80+
return if ids.empty?
81+
82+
with_connection(shard) do |connection|
83+
sql = <<~SQL.squish
84+
DELETE FROM #{connection.quote_table_name(table_name)}
85+
WHERE id IN (#{ids.map { |id| connection.quote(id) }.join(', ')})
86+
SQL
87+
connection.execute(sql)
88+
end
89+
end
90+
91+
def ensure_no_conflicts!(table_name, ids, target:)
92+
return if ids.empty?
93+
94+
with_connection(target) do |connection|
95+
sql = <<~SQL.squish
96+
SELECT 1 FROM #{connection.quote_table_name(table_name)}
97+
WHERE id IN (#{ids.map { |id| connection.quote(id) }.join(', ')})
98+
LIMIT 1
99+
SQL
100+
conflict = connection.select_value(sql)
101+
raise MigrationError, "ID conflict detected for #{table_name}" if conflict
102+
end
103+
end
104+
105+
def with_connection(shard)
106+
PwbTenant::ApplicationRecord.connected_to(role: :writing, shard: shard) do
107+
yield PwbTenant::ApplicationRecord.connection
108+
end
109+
end
110+
111+
def tenant_table_names
112+
@tenant_table_names ||= begin
113+
base_connection = ActiveRecord::Base.connection
114+
base_connection.tables
115+
.reject { |table| table.in?(%w[ar_internal_metadata schema_migrations active_storage_blobs active_storage_attachments]) }
116+
.select do |table|
117+
base_connection.columns(table).any? { |column| column.name == 'website_id' }
118+
end
119+
end
120+
end
121+
122+
def validate_target!
123+
raise MigrationError, 'Website is already on the target shard' if website.database_shard == target_shard
124+
raise MigrationError, "Shard #{target_shard} is not configured" unless physical_shard_configured?(target_shard)
125+
end
126+
127+
def physical_shard_configured?(logical_shard)
128+
Pwb::ShardRegistry.configured?(logical_shard)
129+
end
130+
131+
def normalize_shard(value)
132+
value = value.to_sym if value.respond_to?(:to_sym)
133+
value || :default
134+
end
135+
end
136+
end

docs/multi_tenancy/PREMIUM_ENTERPRISE_SHARDING_PLAN.md

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -117,13 +117,17 @@ Assigns a **new, empty** tenant to a specific shard. Useful for provisioning new
117117
### `rake pwb:sharding:migrate[tenant_id, target_shard]`
118118
**DANGER**: Moves an existing tenant's data from their current shard to the target shard.
119119
* **Usage**: `rake pwb:sharding:migrate[123, shard_1]`
120+
* **Implementation Status**: ✅ Implemented via `Pwb::TenantShardMigrator`. The task copies every `PwbTenant::` model in batches, verifies there are no ID collisions on the destination, inserts the rows, and then deletes them from the source before updating `website.shard_name`.
121+
* **Prerequisites**:
122+
* Target shard must be configured in `database.yml` and up to date schema-wise.
123+
* Destination shard should not contain colliding primary keys for the migrating tenant tables. (The migrator aborts if conflicts are detected.)
120124
* **Steps**:
121-
1. Puts tenant in "Maintenance Mode" (locks site).
122-
2. Dumps data.
123-
3. Restores to target.
124-
4. Verifies row counts match.
125-
5. Updates `website.shard_name`.
126-
6. Unlocks site.
125+
1. Acquire a lock on the website record to prevent concurrent shard changes.
126+
2. Stream tenant data from the source shard in batches.
127+
3. Insert batches into the target shard (raising if any ID conflict is detected).
128+
4. Delete migrated rows from the source shard.
129+
5. Update `website.shard_name`.
130+
6. Release the lock.
127131

128132
## Tenant Admin UI
129133
We will add a "Platform Operations" section to the Super Admin interface (`/admin`).

lib/tasks/sharding.rake

Lines changed: 39 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,58 @@
11
namespace :pwb do
22
namespace :sharding do
3-
desc "List all configured shards and tenant counts"
3+
desc "List shard configuration, databases, and tenant counts"
44
task list: :environment do
5-
shards = PwbTenant::ApplicationRecord.connection_handler.connection_pool_names.map { |n| n.split(":").last }
6-
# Filter for shards defined in our config if possible, or just used the logical names
7-
# Since Rails doesn't easily expose the raw config list at runtime without internal APIs,
8-
# we will check the ones we know about or just query websites
9-
10-
puts "Configured Shards (from database.yml):"
11-
# Rough heuristic to find shard keys
12-
configs = ActiveRecord::Base.configurations.configs_for(env_name: Rails.env)
13-
configs.each do |config|
14-
puts "- #{config.name} (DB: #{config.database})"
15-
end
5+
puts format("%-10s %-25s %-12s", "Shard", "Database", "Tenants")
6+
puts '-' * 55
167

17-
puts "\nTenant Distribution:"
18-
Pwb::Website.group(:shard_name).count.each do |shard, count|
19-
puts " #{shard || 'default'}: #{count} tenants"
8+
Pwb::ShardRegistry.logical_shards.each do |logical|
9+
details = Pwb::ShardRegistry.describe_shard(logical)
10+
tenant_count = Pwb::Website.where(shard_name: logical.to_s).count
11+
database = details[:configured] ? details[:database] : 'not configured'
12+
puts format("%-10s %-25s %-12d", logical, database, tenant_count)
2013
end
2114
end
2215

2316
desc "Provision a new tenant on a specific shard"
2417
task :provision, [:website_id, :shard_name] => :environment do |_, args|
2518
website = Pwb::Website.find(args[:website_id])
26-
target_shard = args[:shard_name]
19+
target_shard = args[:shard_name].presence&.to_sym
20+
21+
unless target_shard
22+
puts 'Usage: rake pwb:sharding:provision[website_id,shard_name]'
23+
next
24+
end
25+
26+
unless Pwb::ShardRegistry.configured?(target_shard)
27+
puts "ERROR: Shard '#{target_shard}' is not configured in database.yml"
28+
next
29+
end
2730

28-
if website.shard_name != "default"
31+
if website.shard_name != 'default'
2932
puts "ERROR: Website is already on shard '#{website.shard_name}'. Migration is required to move it."
3033
next
3134
end
3235

33-
# Update the shard name
34-
website.update!(shard_name: target_shard)
36+
website.update!(shard_name: target_shard.to_s)
3537
puts "Website #{website.id} (#{website.subdomain}) assigned to shard '#{target_shard}'."
36-
puts "Note: This task only updates the pointer. If data already existed, use pwb:sharding:migrate."
38+
puts 'Note: This task only updates the pointer. If data exists, use pwb:sharding:migrate.'
39+
end
40+
41+
desc "Migrate tenant data to a target shard"
42+
task :migrate, [:website_id, :target_shard] => :environment do |_, args|
43+
unless args[:website_id] && args[:target_shard]
44+
puts 'Usage: rake pwb:sharding:migrate[website_id,target_shard]'
45+
next
46+
end
47+
48+
website = Pwb::Website.find(args[:website_id])
49+
target_shard = args[:target_shard].to_sym
50+
51+
migrator = Pwb::TenantShardMigrator.new(website: website, target_shard: target_shard)
52+
migrator.call
53+
puts "Website #{website.id} migrated to #{target_shard}."
54+
rescue Pwb::TenantShardMigrator::MigrationError => e
55+
puts "Migration aborted: #{e.message}"
3756
end
3857
end
3958
end
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# frozen_string_literal: true
2+
3+
require 'rails_helper'
4+
5+
RSpec.describe Pwb::TenantShardMigrator do
6+
let(:website) { create(:pwb_website, shard_name: 'default') }
7+
let(:logger) { Logger.new(nil) }
8+
9+
describe '#call' do
10+
it 'invokes insert/delete cycles and updates shard name' do
11+
migrator = described_class.new(website: website, target_shard: :shard_1, logger: logger)
12+
13+
allow(migrator).to receive(:tenant_table_names).and_return(['pwb_contacts'])
14+
allow(migrator).to receive(:fetch_rows).and_return([
15+
{ 'id' => 42, 'website_id' => website.id, 'first_name' => 'Jane' }
16+
], [])
17+
allow(migrator).to receive(:ensure_no_conflicts!).and_return(true)
18+
19+
expect(migrator).to receive(:insert_rows).with('pwb_contacts', array_including(hash_including('id' => 42)), :shard_1)
20+
expect(migrator).to receive(:delete_rows).with('pwb_contacts', [42], :default)
21+
22+
migrator.call
23+
24+
expect(website.reload.shard_name).to eq('shard_1')
25+
end
26+
27+
it 'raises when the target shard is not configured' do
28+
migrator = described_class.new(website: website, target_shard: :shard_2, logger: logger)
29+
expect { migrator.call }.to raise_error(Pwb::TenantShardMigrator::MigrationError)
30+
end
31+
end
32+
end

0 commit comments

Comments
 (0)