discourse/lib/demon/email_sync.rb
Alan Guo Xiang Tan 23c38cbf11
DEV: Log Unicorn worker timeout backtraces to Rails.logger (#27257)
This commit introduces the following changes:

1. Introduce the `SignalTrapLogger` singleton which starts a single
   thread that polls a queue to log messages with the specified logger.
   This thread is necessary becasue most loggers cannot be used inside
   the `Signal.trap` context as they rely on mutexes which are not
   allowed within the context.

2. Moves the monkey patch in `freedom_patches/unicorn_http_server_patch.rb` to
   `config/unicorn.config.rb` which is already monkey patching
   `Unicorn::HttpServer`.

3. `Unicorn::HttpServer` will now automatically send a `USR2` signal to
   a unicorn worker 2 seconds before the worker is timed out by the
   Unicorn master.

4. When a Unicorn worker receives a `USR2` signal, it will now log only
   the main thread's backtraces to `Rails.logger`. Previously, it was
   `put`ing the backtraces to `STDOUT` which most people wouldn't read.
   Logging it via `Rails.logger` will make the backtraces easily
   accessible via `/logs`.
2024-06-03 12:51:12 +08:00

178 lines
4.9 KiB
Ruby

# frozen_string_literal: true
require "demon/base"
class Demon::EmailSync < ::Demon::Base
HEARTBEAT_KEY ||= "email_sync_heartbeat"
HEARTBEAT_INTERVAL ||= 60.seconds
def self.prefix
"email_sync"
end
private
def suppress_stdout
false
end
def suppress_stderr
false
end
def start_thread(db, group)
Thread.new do
RailsMultisite::ConnectionManagement.with_connection(db) do
ImapSyncLog.debug("Thread started for group #{group.name} in db #{db}", group, db: false)
begin
syncer = Imap::Sync.new(group)
rescue Net::IMAP::NoResponseError => e
group.update(imap_last_error: e.message)
Thread.exit
end
@sync_lock.synchronize { @sync_data[db][group.id][:syncer] = syncer }
status = nil
idle = false
while @running && group.reload.imap_mailbox_name.present?
ImapSyncLog.debug("Processing mailbox for group #{group.name} in db #{db}", group)
status =
syncer.process(
idle: syncer.can_idle? && status && status[:remaining] == 0,
old_emails_limit: status && status[:remaining] > 0 ? 0 : nil,
)
if !syncer.can_idle? && status[:remaining] == 0
ImapSyncLog.debug(
"Going to sleep for group #{group.name} in db #{db} to wait for new emails",
group,
db: false,
)
# Thread goes into sleep for a bit so it is better to return any
# connection back to the pool.
ActiveRecord::Base.connection_handler.clear_active_connections!
sleep SiteSetting.imap_polling_period_mins.minutes
end
end
syncer.disconnect!
end
end
end
def kill_threads
# This is not really safe so the caller should ensure it happens in a
# thread-safe context.
# It should be safe when called from within a `trap` (there are no
# synchronization primitives available anyway).
@running = false
@sync_data.each { |db, sync_data| sync_data.each { |_, data| kill_and_disconnect!(data) } }
exit 0
end
def after_fork
log("[EmailSync] Loading EmailSync in process id #{Process.pid}")
loop do
break if Discourse.redis.set(HEARTBEAT_KEY, Time.now.to_i, ex: HEARTBEAT_INTERVAL, nx: true)
sleep HEARTBEAT_INTERVAL
end
log("[EmailSync] Starting EmailSync main thread")
@running = true
@sync_data = {}
@sync_lock = Mutex.new
trap("INT") { kill_threads }
trap("TERM") { kill_threads }
trap("HUP") { kill_threads }
while @running
Discourse.redis.set(HEARTBEAT_KEY, Time.now.to_i, ex: HEARTBEAT_INTERVAL)
# Kill all threads for databases that no longer exist
all_dbs = Set.new(RailsMultisite::ConnectionManagement.all_dbs)
@sync_data.filter! do |db, sync_data|
next true if all_dbs.include?(db)
sync_data.each { |_, data| kill_and_disconnect!(data) }
false
end
RailsMultisite::ConnectionManagement.each_connection do |db|
next if !SiteSetting.enable_imap
groups = Group.with_imap_configured.map { |group| [group.id, group] }.to_h
@sync_lock.synchronize do
@sync_data[db] ||= {}
# Kill threads for group's mailbox that are no longer synchronized.
@sync_data[db].filter! do |group_id, data|
next true if groups[group_id] && data[:thread]&.alive? && !data[:syncer]&.disconnected?
if !groups[group_id]
ImapSyncLog.warn(
"Killing thread for group because mailbox is no longer synced",
group_id,
)
else
ImapSyncLog.warn("Thread for group is dead", group_id)
end
kill_and_disconnect!(data)
false
end
# Spawn new threads for groups that are now synchronized.
groups.each do |group_id, group|
if !@sync_data[db][group_id]
ImapSyncLog.debug(
"Starting thread for group #{group.name} mailbox #{group.imap_mailbox_name}",
group,
db: false,
)
@sync_data[db][group_id] = { thread: start_thread(db, group), syncer: nil }
end
end
end
end
# Thread goes into sleep for a bit so it is better to return any
# connection back to the pool.
ActiveRecord::Base.connection_handler.clear_active_connections!
sleep 5
end
@sync_lock.synchronize { kill_threads }
Discourse.redis.del(HEARTBEAT_KEY)
exit 0
rescue => e
log("#{e.message}: #{e.backtrace.join("\n")}")
exit 1
end
def kill_and_disconnect!(data)
data[:thread].kill
data[:thread].join
begin
data[:syncer]&.disconnect!
rescue Net::IMAP::ResponseError => err
log(
"[EmailSync] Encountered a response error when disconnecting: #{err}\n#{err.backtrace.join("\n")}",
)
end
end
end