background_services.rb 3.9 KB

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