background_services.rb 5.0 KB

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