background_services.rb 4.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123
  1. # Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
  2. class BackgroundServices
  3. def self.available_services
  4. BackgroundServices::Service.descendants
  5. end
  6. # Waiting time before processes get killed.
  7. SHUTDOWN_GRACE_PERIOD = 30.seconds
  8. class_attribute :shutdown_requested
  9. attr_accessor :threads, :child_pids
  10. attr_reader :config
  11. def initialize(config)
  12. @config = Array(config)
  13. @child_pids = []
  14. @threads = []
  15. install_signal_trap
  16. AppVersion.start_maintenance_thread(process_name: 'background-worker')
  17. Zammad::ProcessDebug.install_thread_status_handler
  18. end
  19. def run
  20. Rails.logger.info 'Starting BackgroundServices...'
  21. # Fork before starting the threads in the main process to ensure a consistent state
  22. # and minimal memory overhead (see also #5420).
  23. config
  24. .in_order_of(:start_as, %i[fork thread])
  25. .each do |service_config|
  26. run_service service_config
  27. end
  28. child_pids.each { |pid| Process.waitpid(pid) }
  29. threads.each(&:join)
  30. ensure
  31. Rails.logger.info('Stopping BackgroundServices.')
  32. end
  33. private
  34. def install_signal_trap
  35. Signal.trap('TERM') { handle_signal('TERM') }
  36. Signal.trap('INT') { handle_signal('INT') }
  37. end
  38. def handle_signal(signal)
  39. # Write operations cannot be handled in a signal handler, use a thread for that.
  40. # This thread is not waited for via `join`, so that the main process should end
  41. # somewhere during the sleep statement if it is able to shut down cleanly.
  42. # If it doesn't, it will send KILL signals to all child processes and the main process
  43. # to enforce the termination.
  44. Thread.new do
  45. Rails.logger.info { "BackgroundServices shutdown requested via #{signal} signal for process #{Process.pid}" }
  46. sleep SHUTDOWN_GRACE_PERIOD
  47. Rails.logger.error { "BackgroundServices did not shutdown cleanly after #{SHUTDOWN_GRACE_PERIOD}s, forcing termination" }
  48. child_pids.each { |pid| Process.kill('KILL', pid) }
  49. Process.kill('KILL', Process.pid)
  50. end
  51. self.class.shutdown_requested = true
  52. child_pids.each do |pid|
  53. Process.kill(signal, pid)
  54. rescue Errno::ESRCH, RangeError
  55. # Don't fail if processes terminated already.
  56. end
  57. end
  58. def run_service(service_config)
  59. if !service_config.enabled?
  60. Rails.logger.info { "Skipping disabled service #{service_config.service.service_name}." }
  61. return
  62. end
  63. if service_config.service.skip?(manager: self)
  64. Rails.logger.info { "Skipping service #{service_config.service.service_name}." }
  65. return
  66. end
  67. service_config.service.pre_run
  68. case service_config.start_as
  69. when :fork
  70. child_pids.push(*start_as_forks(service_config.service, service_config.workers))
  71. when :thread
  72. threads.push start_as_thread(service_config.service)
  73. end
  74. end
  75. def start_as_forks(service, forks)
  76. (1..forks).map do |i|
  77. Process.fork do
  78. Process.setproctitle("#{$PROGRAM_NAME} #{service.service_name}##{i}")
  79. install_signal_trap
  80. Rails.logger.info { "Starting process ##{Process.pid} for service #{service.service_name}." }
  81. service.new(manager: self, fork_id: i).run
  82. rescue Interrupt
  83. nil
  84. end
  85. end
  86. end
  87. def start_as_thread(service)
  88. Thread.new do
  89. Thread.current.abort_on_exception = true
  90. Rails.logger.info { "Starting thread for service #{service.service_name} in the main process." }
  91. service.new(manager: self).run
  92. # BackgroundServices rspec test is using Timeout.timeout to stop background services.
  93. # It was fine for a long time, but started throwing following error in Rails 7.2.
  94. # This seems to affect that test case only.
  95. # Unfortunately, since it's running on a separate thread, that error has to be rescued here.
  96. # That said, this should be handled by improving services loops to support graceful exiting.
  97. rescue ActiveRecord::ActiveRecordError => e
  98. raise e if Rails.env.test? && e.message != 'Cannot expire connection, it is not currently leased.' # rubocop:disable Zammad/DetectTranslatableString
  99. end
  100. end
  101. end