discourse/lib/discourse.rb
Alan Guo Xiang Tan c1f25cdf5b
FIX: Unicorn master and Sidekiq reopening logs at the same time (#29137)
In our production environment, we have been seeing Sidekiq processes
getting stuck randomly when a USR1 signal is sent to the Unicorn master
process. We have not been able to identify the root cause of why the
Sidekiq process gets stuck. We however noticed that when the Unicorn
master process receives a USR1 signal, it will reopen the logs for the
Unicorn master process first before sending a USR1 signal for the
Unicorn worker processes to reopen the logs. We figured that we should
do the same for the Sidekiq process as well when a USR1 signal.

In this commit, we introduce an arbitrary delay of 1 second before we
the Sidekiq process reopens its log files so as to allow enough time for the Unicorn
master to finish reopening it logs first.

We also do not send reopen logs for the Sidekiq process if the `DISCOURSE_LOG_SIDEKIQ`
env is not present because there is no need to reopen any logs.
2024-10-10 08:01:40 +08:00

1231 lines
34 KiB
Ruby

# frozen_string_literal: true
require "cache"
require "open3"
require "plugin/instance"
require "version"
require "git_utils"
module Discourse
DB_POST_MIGRATE_PATH ||= "db/post_migrate"
REQUESTED_HOSTNAME ||= "REQUESTED_HOSTNAME"
MAX_METADATA_FILE_SIZE = 64.kilobytes
class Utils
URI_REGEXP ||= URI.regexp(%w[http https])
# TODO: Remove this once we drop support for Ruby 2.
EMPTY_KEYWORDS ||= {}
# Usage:
# Discourse::Utils.execute_command("pwd", chdir: 'mydirectory')
# or with a block
# Discourse::Utils.execute_command(chdir: 'mydirectory') do |runner|
# runner.exec("pwd")
# end
def self.execute_command(*command, **args)
runner = CommandRunner.new(**args)
if block_given?
if command.present?
raise RuntimeError.new("Cannot pass command and block to execute_command")
end
yield runner
else
runner.exec(*command)
end
end
def self.pretty_logs(logs)
logs.join("\n")
end
def self.logs_markdown(logs, user:, filename: "log.txt")
# Reserve 250 characters for the rest of the text
max_logs_length = SiteSetting.max_post_length - 250
pretty_logs = Discourse::Utils.pretty_logs(logs)
# If logs are short, try to inline them
return <<~TEXT if pretty_logs.size < max_logs_length
```text
#{pretty_logs}
```
TEXT
# Try to create an upload for the logs
upload =
Dir.mktmpdir do |dir|
File.write(File.join(dir, filename), pretty_logs)
zipfile = Compression::Zip.new.compress(dir, filename)
File.open(zipfile) do |file|
UploadCreator.new(
file,
File.basename(zipfile),
type: "backup_logs",
for_export: "true",
).create_for(user.id)
end
end
if upload.persisted?
return UploadMarkdown.new(upload).attachment_markdown
else
Rails.logger.warn("Failed to upload the backup logs file: #{upload.errors.full_messages}")
end
# If logs are long and upload cannot be created, show trimmed logs
<<~TEXT
```text
...
#{pretty_logs.last(max_logs_length)}
```
TEXT
end
def self.atomic_write_file(destination, contents)
begin
return if File.read(destination) == contents
rescue Errno::ENOENT
end
FileUtils.mkdir_p(File.join(Rails.root, "tmp"))
temp_destination = File.join(Rails.root, "tmp", SecureRandom.hex)
File.open(temp_destination, "w") do |fd|
fd.write(contents)
fd.fsync()
end
FileUtils.mv(temp_destination, destination)
nil
end
def self.atomic_ln_s(source, destination)
begin
return if File.readlink(destination) == source
rescue Errno::ENOENT, Errno::EINVAL
end
FileUtils.mkdir_p(File.join(Rails.root, "tmp"))
temp_destination = File.join(Rails.root, "tmp", SecureRandom.hex)
execute_command("ln", "-s", source, temp_destination)
FileUtils.mv(temp_destination, destination)
nil
end
class CommandError < RuntimeError
attr_reader :status, :stdout, :stderr
def initialize(message, status: nil, stdout: nil, stderr: nil)
super(message)
@status = status
@stdout = stdout
@stderr = stderr
end
end
private
class CommandRunner
def initialize(**init_params)
@init_params = init_params
end
def exec(*command, **exec_params)
if (@init_params.keys & exec_params.keys).present?
raise RuntimeError.new("Cannot specify same parameters at block and command level")
end
execute_command(*command, **@init_params.merge(exec_params))
end
private
def execute_command(
*command,
timeout: nil,
failure_message: "",
success_status_codes: [0],
chdir: ".",
unsafe_shell: false
)
env = nil
env = command.shift if command[0].is_a?(Hash)
if !unsafe_shell && (command.length == 1) && command[0].include?(" ")
# Sending a single string to Process.spawn will launch a shell
# This means various things (e.g. subshells) are possible, and could present injection risk
raise "Arguments should be provided as separate strings"
end
if timeout
# will send a TERM after timeout
# will send a KILL after timeout * 2
command = ["timeout", "-k", "#{timeout.to_f * 2}", timeout.to_s] + command
end
args = command
args = [env] + command if env
stdout, stderr, status = Open3.capture3(*args, chdir: chdir)
if !status.exited? || !success_status_codes.include?(status.exitstatus)
failure_message = "#{failure_message}\n" if !failure_message.blank?
raise CommandError.new(
"#{caller[0]}: #{failure_message}#{stderr}",
stdout: stdout,
stderr: stderr,
status: status,
)
end
stdout
end
end
end
def self.job_exception_stats
@job_exception_stats
end
def self.reset_job_exception_stats!
@job_exception_stats = Hash.new(0)
end
reset_job_exception_stats!
if Rails.env.test?
def self.catch_job_exceptions!
raise "tests only" if !Rails.env.test?
@catch_job_exceptions = true
end
def self.reset_catch_job_exceptions!
raise "tests only" if !Rails.env.test?
remove_instance_variable(:@catch_job_exceptions)
end
end
# Log an exception.
#
# If your code is in a scheduled job, it is recommended to use the
# error_context() method in Jobs::Base to pass the job arguments and any
# other desired context.
# See app/jobs/base.rb for the error_context function.
def self.handle_job_exception(ex, context = {}, parent_logger = nil)
return if ex.class == Jobs::HandledExceptionWrapper
context ||= {}
parent_logger ||= Sidekiq
job = context[:job]
# mini_scheduler direct reporting
if Hash === job
job_class = job["class"]
job_exception_stats[job_class] += 1 if job_class
end
# internal reporting
job_exception_stats[job] += 1 if job.class == Class && ::Jobs::Base > job
cm = RailsMultisite::ConnectionManagement
parent_logger.handle_exception(
ex,
{ current_db: cm.current_db, current_hostname: cm.current_hostname }.merge(context),
)
raise ex if Rails.env.test? && !@catch_job_exceptions
end
# Expected less matches than what we got in a find
class TooManyMatches < StandardError
end
# When they try to do something they should be logged in for
class NotLoggedIn < StandardError
end
# When the input is somehow bad
class InvalidParameters < StandardError
end
# When they don't have permission to do something
class InvalidAccess < StandardError
attr_reader :obj
attr_reader :opts
attr_reader :custom_message
attr_reader :custom_message_params
attr_reader :group
def initialize(msg = nil, obj = nil, opts = nil)
super(msg)
@opts = opts || {}
@obj = obj
@custom_message = opts[:custom_message] if @opts[:custom_message]
@custom_message_params = opts[:custom_message_params] if @opts[:custom_message_params]
@group = opts[:group] if @opts[:group]
end
end
# When something they want is not found
class NotFound < StandardError
attr_reader :status
attr_reader :check_permalinks
attr_reader :original_path
attr_reader :custom_message
def initialize(
msg = nil,
status: 404,
check_permalinks: false,
original_path: nil,
custom_message: nil
)
super(msg)
@status = status
@check_permalinks = check_permalinks
@original_path = original_path
@custom_message = custom_message
end
end
# When a setting is missing
class SiteSettingMissing < StandardError
end
# When ImageMagick is missing
class ImageMagickMissing < StandardError
end
# When read-only mode is enabled
class ReadOnly < StandardError
end
# Cross site request forgery
class CSRF < StandardError
end
class Deprecation < StandardError
end
class ScssError < StandardError
end
def self.filters
@filters ||= %i[latest unread new unseen top read posted bookmarks hot]
end
def self.anonymous_filters
@anonymous_filters ||= %i[latest top categories hot]
end
def self.top_menu_items
@top_menu_items ||= Discourse.filters + [:categories]
end
def self.anonymous_top_menu_items
@anonymous_top_menu_items ||= Discourse.anonymous_filters + %i[categories top]
end
# list of pixel ratios Discourse tries to optimize for
PIXEL_RATIOS ||= [1, 1.5, 2, 3]
def self.avatar_sizes
# TODO: should cache these when we get a notification system for site settings
Set.new(SiteSetting.avatar_sizes.split("|").map(&:to_i))
end
def self.activate_plugins!
@plugins = []
@plugins_by_name = {}
Plugin::Instance
.find_all("#{Rails.root}/plugins")
.each do |p|
v = p.metadata.required_version || Discourse::VERSION::STRING
if Discourse.has_needed_version?(Discourse::VERSION::STRING, v)
p.activate!
@plugins << p
@plugins_by_name[p.name] = p
# The plugin directory name and metadata name should match, but that
# is not always the case
dir_name = p.path.split("/")[-2]
if p.name != dir_name
STDERR.puts "Plugin name is '#{p.name}', but plugin directory is named '#{dir_name}'"
# Plugins are looked up by directory name in SiteSettingExtension
# because SiteSetting.load_settings uses directory name as plugin
# name. We alias the two names just to make sure the look up works
@plugins_by_name[dir_name] = p
end
else
STDERR.puts "Could not activate #{p.metadata.name}, discourse does not meet required version (#{v})"
end
end
DiscourseEvent.trigger(:after_plugin_activation)
end
def self.plugins
@plugins ||= []
end
def self.plugins_by_name
@plugins_by_name ||= {}
end
def self.visible_plugins
plugins.filter(&:visible?)
end
def self.plugins_sorted_by_name(enabled_only: true)
if enabled_only
return visible_plugins.filter(&:enabled?).sort_by { |plugin| plugin.humanized_name.downcase }
end
visible_plugins.sort_by { |plugin| plugin.humanized_name.downcase }
end
def self.plugin_themes
@plugin_themes ||= plugins.map(&:themes).flatten
end
def self.official_plugins
plugins.find_all { |p| p.metadata.official? }
end
def self.unofficial_plugins
plugins.find_all { |p| !p.metadata.official? }
end
def self.find_plugins(args)
plugins.select do |plugin|
next if args[:include_official] == false && plugin.metadata.official?
next if args[:include_unofficial] == false && !plugin.metadata.official?
next if !args[:include_disabled] && !plugin.enabled?
true
end
end
def self.apply_asset_filters(plugins, type, request)
filter_opts = asset_filter_options(type, request)
plugins.select { |plugin| plugin.asset_filters.all? { |b| b.call(type, request, filter_opts) } }
end
def self.asset_filter_options(type, request)
result = {}
return result if request.blank?
path = request.fullpath
result[:path] = path if path.present?
result
end
def self.find_plugin_css_assets(args)
plugins = apply_asset_filters(self.find_plugins(args), :css, args[:request])
assets = []
targets = [nil]
targets << :mobile if args[:mobile_view]
targets << :desktop if args[:desktop_view]
targets.each do |target|
assets +=
plugins
.find_all { |plugin| plugin.css_asset_exists?(target) }
.map do |plugin|
target.nil? ? plugin.directory_name : "#{plugin.directory_name}_#{target}"
end
end
assets.map! { |asset| "#{asset}_rtl" } if args[:rtl]
assets
end
def self.find_plugin_js_assets(args)
plugins =
self
.find_plugins(args)
.select do |plugin|
plugin.js_asset_exists? || plugin.extra_js_asset_exists? || plugin.admin_js_asset_exists?
end
plugins = apply_asset_filters(plugins, :js, args[:request])
plugins.flat_map do |plugin|
assets = []
assets << "plugins/#{plugin.directory_name}" if plugin.js_asset_exists?
assets << "plugins/#{plugin.directory_name}_extra" if plugin.extra_js_asset_exists?
# TODO: make admin asset only load for admins
assets << "plugins/#{plugin.directory_name}_admin" if plugin.admin_js_asset_exists?
assets
end
end
def self.assets_digest
@assets_digest ||=
begin
digest = Digest::MD5.hexdigest(ActionView::Base.assets_manifest.assets.values.sort.join)
channel = "/global/asset-version"
message = MessageBus.last_message(channel)
MessageBus.publish channel, digest unless message && message.data == digest
digest
end
end
BUILTIN_AUTH ||= [
Auth::AuthProvider.new(
authenticator: Auth::FacebookAuthenticator.new,
frame_width: 580,
frame_height: 400,
icon: "fab-facebook",
),
Auth::AuthProvider.new(
authenticator: Auth::GoogleOAuth2Authenticator.new,
frame_width: 850,
frame_height: 500,
), # Custom icon implemented in client
Auth::AuthProvider.new(authenticator: Auth::GithubAuthenticator.new, icon: "fab-github"),
Auth::AuthProvider.new(authenticator: Auth::TwitterAuthenticator.new, icon: "fab-twitter"),
Auth::AuthProvider.new(authenticator: Auth::DiscordAuthenticator.new, icon: "fab-discord"),
Auth::AuthProvider.new(
authenticator: Auth::LinkedInOidcAuthenticator.new,
icon: "fab-linkedin-in",
),
]
def self.auth_providers
BUILTIN_AUTH + DiscoursePluginRegistry.auth_providers.to_a
end
def self.enabled_auth_providers
auth_providers.select { |provider| provider.authenticator.enabled? }
end
def self.authenticators
# NOTE: this bypasses the site settings and gives a list of everything, we need to register every middleware
# for the cases of multisite
auth_providers.map(&:authenticator)
end
def self.enabled_authenticators
authenticators.select { |authenticator| authenticator.enabled? }
end
def self.cache
@cache ||=
begin
if GlobalSetting.skip_redis?
ActiveSupport::Cache::MemoryStore.new
else
Cache.new
end
end
end
# hostname of the server, operating system level
# called os_hostname so we do no confuse it with current_hostname
def self.os_hostname
@os_hostname ||=
begin
require "socket"
Socket.gethostname
rescue => e
warn_exception(e, message: "Socket.gethostname is not working")
begin
`hostname`.strip
rescue => e
warn_exception(e, message: "hostname command is not working")
"unknown_host"
end
end
end
# Get the current base URL for the current site
def self.current_hostname
SiteSetting.force_hostname.presence || RailsMultisite::ConnectionManagement.current_hostname
end
def self.base_path(default_value = "")
ActionController::Base.config.relative_url_root.presence || default_value
end
def self.base_uri(default_value = "")
deprecate("Discourse.base_uri is deprecated, use Discourse.base_path instead")
base_path(default_value)
end
def self.base_protocol
SiteSetting.force_https? ? "https" : "http"
end
def self.current_hostname_with_port
default_port = SiteSetting.force_https? ? 443 : 80
result = +"#{current_hostname}"
if SiteSetting.port.to_i > 0 && SiteSetting.port.to_i != default_port
result << ":#{SiteSetting.port}"
end
result << ":#{ENV["UNICORN_PORT"] || 3000}" if Rails.env.development? && SiteSetting.port.blank?
result
end
def self.base_url_no_prefix
"#{base_protocol}://#{current_hostname_with_port}"
end
def self.base_url
base_url_no_prefix + base_path
end
def self.route_for(uri)
unless uri.is_a?(URI)
uri =
begin
URI(uri)
rescue ArgumentError, URI::Error
end
end
return unless uri
path = +(uri.path || "")
if !uri.host ||
(uri.host == Discourse.current_hostname && path.start_with?(Discourse.base_path))
path.slice!(Discourse.base_path)
return Rails.application.routes.recognize_path(path)
end
nil
rescue ActionController::RoutingError
nil
end
class << self
alias_method :base_url_no_path, :base_url_no_prefix
end
def self.urls_cache
@urls_cache ||= DistributedCache.new("urls_cache")
end
def self.tos_url
if SiteSetting.tos_url.present?
SiteSetting.tos_url
else
return urls_cache["tos"] if urls_cache["tos"].present?
tos_url =
if SiteSetting.tos_topic_id > 0 && Topic.exists?(id: SiteSetting.tos_topic_id)
"#{Discourse.base_path}/tos"
end
if tos_url
urls_cache["tos"] = tos_url
else
urls_cache.delete("tos")
end
end
end
def self.privacy_policy_url
if SiteSetting.privacy_policy_url.present?
SiteSetting.privacy_policy_url
else
return urls_cache["privacy_policy"] if urls_cache["privacy_policy"].present?
privacy_policy_url =
if SiteSetting.privacy_topic_id > 0 && Topic.exists?(id: SiteSetting.privacy_topic_id)
"#{Discourse.base_path}/privacy"
end
if privacy_policy_url
urls_cache["privacy_policy"] = privacy_policy_url
else
urls_cache.delete("privacy_policy")
end
end
end
def self.clear_urls!
urls_cache.clear
end
LAST_POSTGRES_READONLY_KEY = "postgres:last_readonly"
READONLY_MODE_KEY_TTL ||= 60
READONLY_MODE_KEY ||= "readonly_mode"
PG_READONLY_MODE_KEY ||= "readonly_mode:postgres"
PG_READONLY_MODE_KEY_TTL ||= 300
USER_READONLY_MODE_KEY ||= "readonly_mode:user"
PG_FORCE_READONLY_MODE_KEY ||= "readonly_mode:postgres_force"
# Pseudo readonly mode, where staff can still write
STAFF_WRITES_ONLY_MODE_KEY ||= "readonly_mode:staff_writes_only"
READONLY_KEYS ||= [
READONLY_MODE_KEY,
PG_READONLY_MODE_KEY,
USER_READONLY_MODE_KEY,
PG_FORCE_READONLY_MODE_KEY,
]
def self.enable_readonly_mode(key = READONLY_MODE_KEY, expires: nil)
if key == PG_READONLY_MODE_KEY || key == PG_FORCE_READONLY_MODE_KEY
Sidekiq.pause!("pg_failover") if !Sidekiq.paused?
end
if expires.nil?
expires = [
USER_READONLY_MODE_KEY,
PG_FORCE_READONLY_MODE_KEY,
STAFF_WRITES_ONLY_MODE_KEY,
].exclude?(key)
end
if expires
ttl =
case key
when PG_READONLY_MODE_KEY
PG_READONLY_MODE_KEY_TTL
else
READONLY_MODE_KEY_TTL
end
Discourse.redis.setex(key, ttl, 1)
keep_readonly_mode(key, ttl: ttl) if !Rails.env.test?
else
Discourse.redis.set(key, 1)
end
MessageBus.publish(readonly_channel, true)
true
end
def self.keep_readonly_mode(key, ttl:)
# extend the expiry by ttl minute every ttl/2 seconds
@mutex ||= Mutex.new
@mutex.synchronize do
@dbs ||= Set.new
@dbs << RailsMultisite::ConnectionManagement.current_db
@threads ||= {}
unless @threads[key]&.alive?
@threads[key] = Thread.new do
while @dbs.size > 0
sleep ttl / 2
@mutex.synchronize do
@dbs.each do |db|
RailsMultisite::ConnectionManagement.with_connection(db) do
@dbs.delete(db) if !Discourse.redis.expire(key, ttl)
end
end
end
end
end
end
end
end
def self.disable_readonly_mode(key = READONLY_MODE_KEY)
if key == PG_READONLY_MODE_KEY || key == PG_FORCE_READONLY_MODE_KEY
Sidekiq.unpause! if Sidekiq.paused?
end
Discourse.redis.del(key)
MessageBus.publish(readonly_channel, false)
true
end
def self.enable_pg_force_readonly_mode
RailsMultisite::ConnectionManagement.each_connection do
enable_readonly_mode(PG_FORCE_READONLY_MODE_KEY)
end
true
end
def self.disable_pg_force_readonly_mode
RailsMultisite::ConnectionManagement.each_connection do
disable_readonly_mode(PG_FORCE_READONLY_MODE_KEY)
end
true
end
def self.readonly_mode?(keys = READONLY_KEYS)
recently_readonly? || GlobalSetting.pg_force_readonly_mode || Discourse.redis.exists?(*keys)
end
def self.staff_writes_only_mode?
Discourse.redis.get(STAFF_WRITES_ONLY_MODE_KEY).present?
end
def self.pg_readonly_mode?
Discourse.redis.get(PG_READONLY_MODE_KEY).present?
end
# Shared between processes
def self.postgres_last_read_only
@postgres_last_read_only ||= DistributedCache.new("postgres_last_read_only")
end
# Per-process
def self.redis_last_read_only
@redis_last_read_only ||= {}
end
def self.postgres_recently_readonly?
seconds =
postgres_last_read_only.defer_get_set("timestamp") { redis.get(LAST_POSTGRES_READONLY_KEY) }
seconds ? Time.zone.at(seconds.to_i) > 15.seconds.ago : false
end
def self.recently_readonly?
redis_read_only = redis_last_read_only[Discourse.redis.namespace]
(redis_read_only.present? && redis_read_only > 15.seconds.ago) || postgres_recently_readonly?
end
def self.received_postgres_readonly!
time = Time.zone.now
redis.set(LAST_POSTGRES_READONLY_KEY, time.to_i.to_s)
postgres_last_read_only.clear(after_commit: false)
time
end
def self.clear_postgres_readonly!
redis.del(LAST_POSTGRES_READONLY_KEY)
postgres_last_read_only.clear(after_commit: false)
end
def self.received_redis_readonly!
redis_last_read_only[Discourse.redis.namespace] = Time.zone.now
end
def self.clear_redis_readonly!
redis_last_read_only[Discourse.redis.namespace] = nil
end
def self.clear_readonly!
clear_redis_readonly!
clear_postgres_readonly!
Site.clear_anon_cache!
true
end
def self.request_refresh!(user_ids: nil)
# Causes refresh on next click for all clients
#
# This is better than `MessageBus.publish "/file-change", ["refresh"]` because
# it spreads the refreshes out over a time period
if user_ids
MessageBus.publish("/refresh_client", "clobber", user_ids: user_ids)
else
MessageBus.publish("/global/asset-version", "clobber")
end
end
def self.git_version
@git_version ||= GitUtils.git_version
end
def self.git_branch
@git_branch ||= GitUtils.git_branch
end
def self.full_version
@full_version ||= GitUtils.full_version
end
def self.last_commit_date
@last_commit_date ||= GitUtils.last_commit_date
end
def self.try_git(git_cmd, default_value)
GitUtils.try_git(git_cmd, default_value)
end
# Either returns the site_contact_username user or the first admin.
def self.site_contact_user
user =
User.find_by(
username_lower: SiteSetting.site_contact_username.downcase,
) if SiteSetting.site_contact_username.present?
user ||= (system_user || User.admins.real.order(:id).first)
end
SYSTEM_USER_ID ||= -1
def self.system_user
@system_users ||= {}
current_db = RailsMultisite::ConnectionManagement.current_db
@system_users[current_db] ||= User.find_by(id: SYSTEM_USER_ID)
end
def self.store
if SiteSetting.Upload.enable_s3_uploads
@s3_store_loaded ||= require "file_store/s3_store"
FileStore::S3Store.new
else
@local_store_loaded ||= require "file_store/local_store"
FileStore::LocalStore.new
end
end
def self.stats
PluginStore.new("stats")
end
def self.current_user_provider
@current_user_provider || Auth::DefaultCurrentUserProvider
end
def self.current_user_provider=(val)
@current_user_provider = val
end
def self.asset_host
Rails.configuration.action_controller.asset_host
end
def self.readonly_channel
"/site/read-only"
end
# all forking servers must call this
# before forking, otherwise the forked process might
# be in a bad state
def self.before_fork
# V8 does not support forking, make sure all contexts are disposed
ObjectSpace.each_object(MiniRacer::Context) { |c| c.dispose }
# get rid of rubbish so we don't share it
# longer term we will use compact! here
GC.start
GC.start
GC.start
end
# all forking servers must call this
# after fork, otherwise Discourse will be
# in a bad state
def self.after_fork
# note: some of this reconnecting may no longer be needed per https://github.com/redis/redis-rb/pull/414
MessageBus.after_fork
SiteSetting.after_fork
Discourse.redis.reconnect
Rails.cache.reconnect
Discourse.cache.reconnect
Logster.store.redis.reconnect
# shuts down all connections in the pool
Sidekiq.redis_pool.shutdown { |conn| conn.disconnect! }
# re-establish
Sidekiq.redis = sidekiq_redis_config
# in case v8 was initialized we want to make sure it is nil
PrettyText.reset_context
DiscourseJsProcessor::Transpiler.reset_context if defined?(DiscourseJsProcessor::Transpiler)
# warm up v8 after fork, that way we do not fork a v8 context
# it may cause issues if bg threads in a v8 isolate randomly stop
# working due to fork
begin
# Skip warmup in development mode - it makes boot take ~2s longer
PrettyText.cook("warm up **pretty text**") if !Rails.env.development?
rescue => e
Rails.logger.error("Failed to warm up pretty text: #{e}\n#{e.backtrace.join("\n")}")
end
nil
end
# you can use Discourse.warn when you want to report custom environment
# with the error, this helps with grouping
def self.warn(message, env = nil)
append = env ? (+" ") << env.map { |k, v| "#{k}: #{v}" }.join(" ") : ""
loggers = Rails.logger.broadcasts
logster_env = env
if old_env = Thread.current[Logster::Logger::LOGSTER_ENV]
logster_env = Logster::Message.populate_from_env(old_env)
# a bit awkward by try to keep the new params
env.each { |k, v| logster_env[k] = v }
end
loggers.each do |logger|
if !(Logster::Logger === logger)
logger.warn("#{message} #{append}")
next
end
logger.store.report(::Logger::Severity::WARN, "discourse", message, env: logster_env)
end
if old_env
env.each do |k, v|
# do not leak state
logster_env.delete(k)
end
end
nil
end
# report a warning maintaining backtrack for logster
def self.warn_exception(e, message: "", env: nil)
if Rails.logger.respond_to? :add_with_opts
env ||= {}
env[:current_db] ||= RailsMultisite::ConnectionManagement.current_db
# logster
Rails.logger.add_with_opts(
::Logger::Severity::WARN,
"#{message} : #{e.class.name} : #{e}",
"discourse-exception",
backtrace: e.backtrace.join("\n"),
env: env,
)
else
# no logster ... fallback
Rails.logger.warn("#{message} #{e}\n#{e.backtrace.join("\n")}")
end
rescue StandardError
STDERR.puts "Failed to report exception #{e} #{message}"
end
def self.capture_exceptions(message: "", env: nil)
yield
rescue Exception => e
Discourse.warn_exception(e, message: message, env: env)
nil
end
def self.deprecate(warning, drop_from: nil, since: nil, raise_error: false, output_in_test: false)
location = caller_locations[1].yield_self { |l| "#{l.path}:#{l.lineno}:in \`#{l.label}\`" }
warning = ["Deprecation notice:", warning]
warning << "(deprecated since Discourse #{since})" if since
warning << "(removal in Discourse #{drop_from})" if drop_from
warning << "\nAt #{location}"
warning = warning.join(" ")
raise Deprecation.new(warning) if raise_error
STDERR.puts(warning) if Rails.env.development?
STDERR.puts(warning) if output_in_test && Rails.env.test?
digest = Digest::MD5.hexdigest(warning)
redis_key = "deprecate-notice-#{digest}"
if !Rails.env.development? && Rails.logger && !GlobalSetting.skip_redis? &&
!Discourse.redis.without_namespace.get(redis_key)
Rails.logger.warn(warning)
begin
Discourse.redis.without_namespace.setex(redis_key, 3600, "x")
rescue Redis::CommandError => e
raise unless e.message =~ /READONLY/
end
end
warning
end
SIDEKIQ_NAMESPACE ||= "sidekiq"
def self.sidekiq_redis_config
conf = GlobalSetting.redis_config.dup
conf[:namespace] = SIDEKIQ_NAMESPACE
conf
end
def self.static_doc_topic_ids
[SiteSetting.tos_topic_id, SiteSetting.guidelines_topic_id, SiteSetting.privacy_topic_id]
end
def self.site_creation_date
@creation_dates ||= {}
current_db = RailsMultisite::ConnectionManagement.current_db
@creation_dates[current_db] ||= begin
result = DB.query_single <<~SQL
SELECT created_at
FROM schema_migration_details
ORDER BY created_at
LIMIT 1
SQL
result.first
end
end
def self.clear_site_creation_date_cache
@creation_dates = {}
end
cattr_accessor :last_ar_cache_reset
def self.reset_active_record_cache_if_needed(e)
last_cache_reset = Discourse.last_ar_cache_reset
if e && e.message =~ /UndefinedColumn/ &&
(last_cache_reset.nil? || last_cache_reset < 30.seconds.ago)
Rails.logger.warn "Clearing Active Record cache, this can happen if schema changed while site is running or in a multisite various databases are running different schemas. Consider running rake multisite:migrate."
Discourse.last_ar_cache_reset = Time.zone.now
Discourse.reset_active_record_cache
end
end
def self.reset_active_record_cache
ActiveRecord::Base.connection.query_cache.clear
(ActiveRecord::Base.connection.tables - %w[schema_migrations versions]).each do |table|
begin
table.classify.constantize.reset_column_information
rescue StandardError
nil
end
end
nil
end
def self.running_in_rack?
ENV["DISCOURSE_RUNNING_IN_RACK"] == "1"
end
def self.skip_post_deployment_migrations?
%w[1 true].include?(ENV["SKIP_POST_DEPLOYMENT_MIGRATIONS"]&.to_s)
end
# this is used to preload as much stuff as possible prior to forking
# in turn this can conserve large amounts of memory on forking servers
def self.preload_rails!
return if @preloaded_rails
if !Rails.env.development?
# Skipped in development because the schema cache gets reset on every code change anyway
# Better to rely on the filesystem-based db:schema:cache:dump
# load up all models and schema
(ActiveRecord::Base.connection.tables - %w[schema_migrations versions]).each do |table|
begin
table.classify.constantize.first
rescue StandardError
nil
end
end
# ensure we have a full schema cache in case we missed something above
ActiveRecord::Base.connection.data_sources.each do |table|
ActiveRecord::Base.connection.schema_cache.add(table)
end
end
RailsMultisite::ConnectionManagement.safe_each_connection do
I18n.t(:posts)
# this will force Cppjieba to preload if any site has it
# enabled allowing it to be reused between all child processes
Search.prepare_data("test")
JsLocaleHelper.load_translations(SiteSetting.default_locale)
Site.json_for(Guardian.new)
SvgSprite.preload
begin
SiteSetting.client_settings_json
rescue => e
# Rescue from Redis related errors so that we can still boot the
# application even if Redis is down.
warn_exception(e, message: "Error while preloading client settings json")
end
end
[
Thread.new do
# router warm up
begin
Rails.application.routes.recognize_path("abc")
rescue StandardError
nil
end
end,
Thread.new do
# preload discourse version
Discourse.git_version
Discourse.git_branch
Discourse.full_version
Discourse.plugins.each { |p| p.commit_url }
end,
Thread.new do
require "actionview_precompiler"
ActionviewPrecompiler.precompile
end,
Thread.new { LetterAvatar.image_magick_version },
Thread.new { SvgSprite.core_svgs },
Thread.new { EmberCli.script_chunks },
].each(&:join)
ensure
@preloaded_rails = true
end
mattr_accessor :redis
def self.is_parallel_test?
ENV["RAILS_ENV"] == "test" && ENV["TEST_ENV_NUMBER"]
end
CDN_REQUEST_METHODS ||= %w[GET HEAD OPTIONS]
def self.is_cdn_request?(env, request_method)
return if CDN_REQUEST_METHODS.exclude?(request_method)
cdn_hostnames = GlobalSetting.cdn_hostnames
return if cdn_hostnames.blank?
requested_hostname = env[REQUESTED_HOSTNAME] || env[Rack::HTTP_HOST]
cdn_hostnames.include?(requested_hostname)
end
def self.apply_cdn_headers(headers)
headers["Access-Control-Allow-Origin"] = "*"
headers["Access-Control-Allow-Methods"] = CDN_REQUEST_METHODS.join(", ")
headers
end
def self.allow_dev_populate?
Rails.env.development? || ENV["ALLOW_DEV_POPULATE"] == "1"
end
# warning: this method is very expensive and shouldn't be called in places
# where performance matters. it's meant to be called manually (e.g. in the
# rails console) when dealing with an emergency that requires invalidating
# theme cache
def self.clear_all_theme_cache!
ThemeField.force_recompilation!
Theme.all.each(&:update_javascript_cache!)
Theme.expire_site_cache!
end
def self.anonymous_locale(request)
locale =
HttpLanguageParser.parse(request.cookies["locale"]) if SiteSetting.set_locale_from_cookie
locale ||=
HttpLanguageParser.parse(
request.env["HTTP_ACCEPT_LANGUAGE"],
) if SiteSetting.set_locale_from_accept_language_header
locale
end
def self.enable_sidekiq_logging?
ENV["DISCOURSE_LOG_SIDEKIQ"] == "1"
end
end