discourse/lib/tasks/db.rake
Alan Guo Xiang Tan 416cef9ed1
DEV: Respect SKIP_TEST_DATABASE when running rake db:create (#24407)
Why this change?

By default the `db:create` Rake task in activerecord creates the
databases for both the development and test environment. This while
seemingly odd is by design from Rails. In order to avoid creating the
test database, Rails supports the `SKIP_TEST_DATABASE` environment
variable which we should respect when creating the multisite test
database.
2023-11-16 20:01:12 +08:00

603 lines
16 KiB
Ruby

# frozen_string_literal: true
# we should set the locale before the migration
task "set_locale" do
begin
I18n.locale =
begin
(SiteSetting.default_locale || :en)
rescue StandardError
:en
end
rescue I18n::InvalidLocale
I18n.locale = :en
end
end
module MultisiteTestHelpers
def self.load_multisite?
Rails.env.test? && !ENV["RAILS_DB"] && !ENV["SKIP_MULTISITE"]
end
def self.create_multisite?
(ENV["RAILS_ENV"] == "test" || !ENV["RAILS_ENV"]) && !ENV["RAILS_DB"] &&
!ENV["SKIP_MULTISITE"] && !ENV["SKIP_TEST_DATABASE"]
end
end
task "db:environment:set" => [:load_config] do |_, args|
if MultisiteTestHelpers.load_multisite?
system("RAILS_ENV=test RAILS_DB=discourse_test_multisite rake db:environment:set")
end
end
task "db:force_skip_persist" do
GlobalSetting.skip_db = true
GlobalSetting.skip_redis = true
end
task "db:create" => [:load_config] do |_, args|
if MultisiteTestHelpers.create_multisite?
unless system("RAILS_ENV=test RAILS_DB=discourse_test_multisite rake db:create")
STDERR.puts "-" * 80
STDERR.puts "ERROR: Could not create multisite DB. A common cause of this is a plugin"
STDERR.puts "checking the column structure when initializing, which raises an error."
STDERR.puts "-" * 80
raise "Could not initialize discourse_test_multisite"
end
end
end
begin
reqs = Rake::Task["db:create"].prerequisites.map(&:to_sym)
Rake::Task["db:create"].clear_prerequisites
Rake::Task["db:create"].enhance(["db:force_skip_persist"] + reqs)
end
task "db:drop" => [:load_config] do |_, args|
if MultisiteTestHelpers.create_multisite?
system("RAILS_DB=discourse_test_multisite RAILS_ENV=test rake db:drop")
end
end
begin
Rake::Task["db:migrate"].clear
Rake::Task["db:rollback"].clear
end
task "db:rollback" => %w[environment set_locale] do |_, args|
step = ENV["STEP"] ? ENV["STEP"].to_i : 1
ActiveRecord::Base.connection.migration_context.rollback(step)
Rake::Task["db:_dump"].invoke
end
# our optimized version of multisite migrate, we have many sites and we have seeds
# this ensures we can run migrations concurrently to save huge amounts of time
Rake::Task["multisite:migrate"].clear
class StdOutDemux
def initialize(stdout)
@stdout = stdout
@data = {}
end
def write(data)
(@data[Thread.current] ||= +"") << data
end
def close
finish_chunk
end
def finish_chunk
data = @data[Thread.current]
if data
@stdout.write(data)
@data.delete Thread.current
end
end
def flush
# Do nothing
end
end
class SeedHelper
def self.paths
DiscoursePluginRegistry.seed_paths
end
def self.filter
# Allows a plugin to exclude any specified seed data files from running
if DiscoursePluginRegistry.seedfu_filter.any?
/\A(?!.*(#{DiscoursePluginRegistry.seedfu_filter.to_a.join("|")})).*\z/
else
nil
end
end
end
task "multisite:migrate" => %w[
db:load_config
environment
set_locale
assets:precompile:theme_transpiler
] do |_, args|
raise "Multisite migrate is only supported in production" if ENV["RAILS_ENV"] != "production"
DistributedMutex.synchronize(
"db_migration",
redis: Discourse.redis.without_namespace,
validity: 1200,
) do
# TODO: Switch to processes for concurrent migrations because Rails migration
# is not thread safe by default.
concurrency = 1
puts "Multisite migrator is running using #{concurrency} threads"
puts
exceptions = Queue.new
if concurrency > 1
old_stdout = $stdout
$stdout = StdOutDemux.new($stdout)
end
SeedFu.quiet = true
def execute_concurrently(concurrency, exceptions)
queue = Queue.new
RailsMultisite::ConnectionManagement.each_connection { |db| queue << db }
concurrency.times { queue << :done }
(1..concurrency)
.map do
Thread.new do
while true
db = queue.pop
break if db == :done
RailsMultisite::ConnectionManagement.with_connection(db) do
begin
yield(db) if block_given?
rescue => e
exceptions << [db, e]
ensure
begin
$stdout.finish_chunk if concurrency > 1
rescue => ex
STDERR.puts ex.inspect
STDERR.puts ex.backtrace
end
end
end
end
end
end
.each(&:join)
end
def check_exceptions(exceptions)
if exceptions.length > 0
STDERR.puts
STDERR.puts "-" * 80
STDERR.puts "#{exceptions.length} migrations failed!"
while !exceptions.empty?
db, e = exceptions.pop
STDERR.puts
STDERR.puts "Failed to migrate #{db}"
STDERR.puts e.inspect
STDERR.puts e.backtrace
STDERR.puts
end
exit 1
end
end
execute_concurrently(concurrency, exceptions) do |db|
puts "Migrating #{db}"
ActiveRecord::Tasks::DatabaseTasks.migrate
end
check_exceptions(exceptions)
SeedFu.seed(SeedHelper.paths, /001_refresh/)
execute_concurrently(concurrency, exceptions) do |db|
puts "Seeding #{db}"
SeedFu.seed(SeedHelper.paths, SeedHelper.filter)
if !Discourse.skip_post_deployment_migrations? && ENV["SKIP_OPTIMIZE_ICONS"] != "1"
SiteIconManager.ensure_optimized!
end
end
$stdout = old_stdout if concurrency > 1
check_exceptions(exceptions)
Rake::Task["db:_dump"].invoke
end
end
task "db:migrate" => %w[
load_config
environment
set_locale
assets:precompile:theme_transpiler
] do |_, args|
DistributedMutex.synchronize(
"db_migration",
redis: Discourse.redis.without_namespace,
validity: 300,
) do
migrations = ActiveRecord::Base.connection.migration_context.migrations
now_timestamp = Time.now.utc.strftime("%Y%m%d%H%M%S").to_i
epoch_timestamp = Time.at(0).utc.strftime("%Y%m%d%H%M%S").to_i
if migrations.last.version > now_timestamp
raise "Migration #{migrations.last.version} is timestamped in the future"
end
if migrations.first.version < epoch_timestamp
raise "Migration #{migrations.first.version} is timestamped before the epoch"
end
%i[pg_trgm unaccent].each do |extension|
begin
DB.exec "CREATE EXTENSION IF NOT EXISTS #{extension}"
rescue => e
STDERR.puts "Cannot enable database extension #{extension}"
STDERR.puts e
end
end
ActiveRecord::Tasks::DatabaseTasks.migrate
SeedFu.quiet = true
SeedFu.seed(SeedHelper.paths, SeedHelper.filter)
Rake::Task["db:schema:cache:dump"].invoke if Rails.env.development? && !ENV["RAILS_DB"]
if !Discourse.skip_post_deployment_migrations? && ENV["SKIP_OPTIMIZE_ICONS"] != "1"
SiteIconManager.ensure_optimized!
end
end
if !Discourse.is_parallel_test? && MultisiteTestHelpers.load_multisite?
system("RAILS_DB=discourse_test_multisite rake db:migrate")
end
end
task "test:prepare" => "environment" do
I18n.locale =
begin
SiteSetting.default_locale
rescue StandardError
:en
end
SeedFu.seed(DiscoursePluginRegistry.seed_paths)
end
task "db:api_test_seed" => "environment" do
puts "Loading test data for discourse_api"
load Rails.root + "db/api_test_seeds.rb"
end
def print_table(array)
width = array[0].keys.map { |k| k.to_s.length }
cols = array[0].keys.length
array.each do |row|
row.each_with_index { |(_, val), i| width[i] = [width[i].to_i, val.to_s.length].max }
end
array[0].keys.each_with_index do |col, i|
print col.to_s.ljust(width[i], " ")
if i == cols - 1
puts
else
print " | "
end
end
puts "-" * (width.sum + width.length)
array.each do |row|
row.each_with_index do |(_, val), i|
print val.to_s.ljust(width[i], " ")
if i == cols - 1
puts
else
print " | "
end
end
end
end
desc "Statistics about database"
task "db:stats" => "environment" do
sql = <<~SQL
select table_name,
(
select reltuples::bigint
from pg_class
where oid = ('public.' || table_name)::regclass
) AS row_estimate,
pg_size_pretty(pg_table_size(quote_ident(table_name))) table_size,
pg_size_pretty(pg_indexes_size(quote_ident(table_name))) index_size,
pg_size_pretty(pg_total_relation_size(quote_ident(table_name))) total_size
from information_schema.tables
where table_schema = 'public'
order by pg_total_relation_size(quote_ident(table_name)) DESC
SQL
puts
print_table(DB.query_hash(sql))
end
task "db:ensure_post_migrations" do
if %w[1 true].include?(ENV["SKIP_POST_DEPLOYMENT_MIGRATIONS"])
cmd = `cat /proc/#{Process.pid}/cmdline | xargs -0 echo`
ENV["SKIP_POST_DEPLOYMENT_MIGRATIONS"] = "0"
exec cmd
end
end
class NormalizedIndex
attr_accessor :name, :original, :normalized, :table
def initialize(original)
@original = original
@normalized = original.sub(/(create.*index )(\S+)(.*)/i, '\1idx\3')
@name = original.match(/create.*index (\S+)/i)[1]
@table = original.match(/create.*index \S+ on public\.(\S+)/i)[1]
end
def ==(other)
other&.normalized == normalized
end
end
def normalize_index_names(names)
names.map { |name| NormalizedIndex.new(name) }.reject { |i| i.name.include?("ccnew") }
end
desc "Validate indexes"
task "db:validate_indexes", [:arg] => %w[db:ensure_post_migrations environment] do |_, args|
db = TemporaryDb.new
db.start
db.migrate
ActiveRecord::Base.establish_connection(
adapter: "postgresql",
database: "discourse",
port: db.pg_port,
host: "localhost",
)
expected = DB.query_single <<~SQL
SELECT indexdef FROM pg_indexes
WHERE schemaname = 'public'
ORDER BY indexdef
SQL
expected_tables = DB.query_single <<~SQL
SELECT tablename
FROM pg_tables
WHERE schemaname = 'public'
SQL
ActiveRecord::Base.establish_connection
db.stop
puts
fix_indexes = (ENV["FIX_INDEXES"] == "1" || args[:arg] == "fix")
inconsistency_found = false
RailsMultisite::ConnectionManagement.each_connection do |db_name|
puts "Testing indexes on the #{db_name} database", ""
current = DB.query_single <<~SQL
SELECT indexdef FROM pg_indexes
WHERE schemaname = 'public'
ORDER BY indexdef
SQL
missing = expected - current
extra = current - expected
extra.reject! { |x| x =~ /idx_recent_regular_post_search_data/ }
renames = []
normalized_missing = normalize_index_names(missing)
normalized_extra = normalize_index_names(extra)
normalized_extra.each do |extra_index|
if missing_index = normalized_missing.select { |x| x == extra_index }.first
renames << [extra_index, missing_index]
missing.delete missing_index.original
extra.delete extra_index.original
end
end
next if db_name != "default" && renames.length == 0 && missing.length == 0 && extra.length == 0
if renames.length > 0
inconsistency_found = true
puts "Renamed indexes"
renames.each do |extra_index, missing_index|
puts "#{extra_index.name} should be renamed to #{missing_index.name}"
end
puts
if fix_indexes
puts "fixing indexes"
renames.each do |extra_index, missing_index|
DB.exec "ALTER INDEX #{extra_index.name} RENAME TO #{missing_index.name}"
end
puts
end
end
if missing.length > 0
inconsistency_found = true
puts "Missing Indexes", ""
missing.each { |m| puts m }
if fix_indexes
puts "Adding missing indexes..."
missing.each do |m|
begin
DB.exec(m)
rescue => e
$stderr.puts "Error running: #{m} - #{e}"
end
end
end
else
puts "No missing indexes", ""
end
if extra.length > 0
inconsistency_found = true
puts "", "Extra Indexes", ""
extra.each { |e| puts e }
if fix_indexes
puts "Removing extra indexes"
extra.each do |statement|
if match = /create .*index (\S+) on public\.(\S+)/i.match(statement)
index_name, table_name = match[1], match[2]
if expected_tables.include?(table_name)
puts "Dropping #{index_name}"
begin
DB.exec("DROP INDEX #{index_name}")
rescue => e
$stderr.puts "Error dropping index #{index_name} - #{e}"
end
else
$stderr.puts "Skipping #{index_name} since #{table_name} should not exist - maybe an old plugin created it"
end
else
$stderr.puts "ERROR - BAD REGEX - UNABLE TO PARSE INDEX - #{statement}"
end
end
end
else
puts "No extra indexes", ""
end
end
exit 1 if inconsistency_found && !fix_indexes
end
desc "Rebuild indexes"
task "db:rebuild_indexes" => "environment" do
if Import.backup_tables_count > 0
raise "Backup from a previous import exists. Drop them before running this job with rake import:remove_backup, or move them to another schema."
end
Discourse.enable_readonly_mode
backup_schema = Jobs::Importer::BACKUP_SCHEMA
table_names =
DB.query_single(
"select table_name from information_schema.tables where table_schema = 'public'",
)
begin
# Move all tables to the backup schema:
DB.exec("DROP SCHEMA IF EXISTS #{backup_schema} CASCADE")
DB.exec("CREATE SCHEMA #{backup_schema}")
table_names.each do |table_name|
DB.exec("ALTER TABLE public.#{table_name} SET SCHEMA #{backup_schema}")
end
# Create a new empty db
Rake::Task["db:migrate"].invoke
# Fetch index definitions from the new db
index_definitions = {}
table_names.each do |table_name|
index_definitions[table_name] = DB.query_single(
"SELECT indexdef FROM pg_indexes WHERE tablename = '#{table_name}' and schemaname = 'public';",
)
end
# Drop the new tables
table_names.each { |table_name| DB.exec("DROP TABLE public.#{table_name}") }
# Move the old tables back to the public schema
table_names.each do |table_name|
DB.exec("ALTER TABLE #{backup_schema}.#{table_name} SET SCHEMA public")
end
# Drop their indexes
index_names =
DB.query_single(
"SELECT indexname FROM pg_indexes WHERE schemaname = 'public' AND tablename IN ('#{table_names.join("', '")}')",
)
index_names.each do |index_name|
begin
puts index_name
DB.exec("DROP INDEX public.#{index_name}")
rescue ActiveRecord::StatementInvalid
# It's this:
# PG::Error: ERROR: cannot drop index category_users_pkey because constraint category_users_pkey on table category_users requires it
# HINT: You can drop constraint category_users_pkey on table category_users instead.
end
end
# Create the indexes
table_names.each do |table_name|
index_definitions[table_name].each do |index_def|
begin
DB.exec(index_def)
rescue ActiveRecord::StatementInvalid
# Trying to recreate a primary key
end
end
end
rescue StandardError
# Can we roll this back?
raise
ensure
Discourse.disable_readonly_mode
end
end
desc "Check that the DB can be accessed"
task "db:status:json" do
begin
Rake::Task["environment"].invoke
DB.query("SELECT 1")
rescue StandardError
puts({ status: "error" }.to_json)
else
puts({ status: "ok" }.to_json)
end
end
desc "Grow notification id column to a big int in case of overflow"
task "db:resize:notification_id" => :environment do
sql = <<~SQL
SELECT table_name, column_name FROM INFORMATION_SCHEMA.columns
WHERE (column_name like '%notification_id' OR column_name = 'id' and table_name = 'notifications') AND data_type = 'integer'
SQL
DB
.query(sql)
.each do |row|
puts "Changing #{row.table_name}(#{row.column_name}) to a bigint"
DB.exec("ALTER table #{row.table_name} ALTER COLUMN #{row.column_name} TYPE BIGINT")
end
end