From e9be407d22884895bc70d54479c05c0fa9243e7f Mon Sep 17 00:00:00 2001 From: Ben Sheldon Date: Thu, 21 Jan 2021 18:52:15 -0800 Subject: [PATCH] Add daemonize option to CLI --- README.md | 3 ++ lib/good_job/cli.rb | 16 ++++++++- lib/good_job/configuration.rb | 16 ++++++++- lib/good_job/daemon.rb | 59 ++++++++++++++++++++++++++++++++ spec/lib/good_job/daemon_spec.rb | 39 +++++++++++++++++++++ 5 files changed, 131 insertions(+), 2 deletions(-) create mode 100644 lib/good_job/daemon.rb create mode 100644 spec/lib/good_job/daemon_spec.rb diff --git a/README.md b/README.md index a5dcf52e9..91ab057f4 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,8 @@ Options: [--max-threads=COUNT] # Maximum number of threads to use for working jobs. (env var: GOOD_JOB_MAX_THREADS, default: 5) [--queues=QUEUE_LIST] # Queues to work from. (env var: GOOD_JOB_QUEUES, default: *) [--poll-interval=SECONDS] # Interval between polls for available jobs in seconds (env var: GOOD_JOB_POLL_INTERVAL, default: 1) + [--daemonize] # Run as a background daemon (default: false) + [--pidfile=PIDFILE] # Path to write daemonized Process ID (env var: GOOD_JOB_PIDFILE, default: tmp/pids/good_job.pid) Executes queued jobs. @@ -159,6 +161,7 @@ All options can be configured with environment variables. See option descriptions for the matching environment variable name. == Configuring queues + Separate multiple queues with commas; exclude queues with a leading minus; separate isolated execution pools with semicolons and threads with colons. ``` diff --git a/lib/good_job/cli.rb b/lib/good_job/cli.rb index faa6c7df3..8d7f78f39 100644 --- a/lib/good_job/cli.rb +++ b/lib/good_job/cli.rb @@ -15,6 +15,11 @@ class CLI < Thor # Requiring this loads the application's configuration and classes. RAILS_ENVIRONMENT_RB = File.expand_path("config/environment.rb") + # @!visibility private + def self.exit_on_failure? + true + end + # @!macro thor.desc # @!method $1 # @return [void] @@ -27,7 +32,8 @@ class CLI < Thor See option descriptions for the matching environment variable name. == Configuring queues - \x5Separate multiple queues with commas; exclude queues with a leading minus; + + Separate multiple queues with commas; exclude queues with a leading minus; separate isolated execution pools with semicolons and threads with colons. DESCRIPTION @@ -43,10 +49,18 @@ class CLI < Thor type: :numeric, banner: 'SECONDS', desc: "Interval between polls for available jobs in seconds (env var: GOOD_JOB_POLL_INTERVAL, default: 5)" + method_option :daemonize, + type: :boolean, + desc: "Run as a background daemon (default: false)" + method_option :pidfile, + type: :string, + desc: "Path to write daemonized Process ID (env var: GOOD_JOB_PIDFILE, default: tmp/pids/good_job.pid)" def start set_up_application! configuration = GoodJob::Configuration.new(options) + Daemon.new(pidfile: configuration.pidfile).daemonize if configuration.daemonize? + notifier = GoodJob::Notifier.new poller = GoodJob::Poller.new(poll_interval: configuration.poll_interval) scheduler = GoodJob::Scheduler.from_configuration(configuration) diff --git a/lib/good_job/configuration.rb b/lib/good_job/configuration.rb index e4ee9971b..894024293 100644 --- a/lib/good_job/configuration.rb +++ b/lib/good_job/configuration.rb @@ -108,7 +108,7 @@ def poll_interval # Number of seconds to preserve jobs when using the +good_job cleanup_preserved_jobs+ CLI command. # This configuration is only used when {GoodJob.preserve_job_records} is +true+. - # @return [Boolean] + # @return [Integer] def cleanup_preserved_jobs_before_seconds_ago ( options[:before_seconds_ago] || @@ -118,6 +118,20 @@ def cleanup_preserved_jobs_before_seconds_ago ).to_i end + # Tests whether to daemonize the process. + # @return [Boolean] + def daemonize? + options[:daemonize] || false + end + + # Path of the pidfile to create when running as a daemon. + # @return [Pathname,String] + def pidfile + options[:pidfile] || + env['GOOD_JOB_PIDFILE'] || + Rails.application.root.join('tmp', 'pids', 'good_job.pid') + end + private def rails_config diff --git a/lib/good_job/daemon.rb b/lib/good_job/daemon.rb new file mode 100644 index 000000000..84c71a1e4 --- /dev/null +++ b/lib/good_job/daemon.rb @@ -0,0 +1,59 @@ +module GoodJob + # + # Manages daemonization of the current process. + # + class Daemon + # The path of the generated pidfile. + # @return [Pathname,String] + attr_reader :pidfile + + # @param pidfile [Pathname,String] Pidfile path + def initialize(pidfile:) + @pidfile = pidfile + end + + # Daemonizes the current process and writes out a pidfile. + def daemonize + check_pid + Process.daemon + write_pid + end + + private + + def write_pid + File.open(pidfile, ::File::CREAT | ::File::EXCL | ::File::WRONLY) { |f| f.write(Process.pid.to_s) } + at_exit { File.delete(pidfile) if File.exist?(pidfile) } + rescue Errno::EEXIST + check_pid + retry + end + + def delete_pid + File.delete(pidfile) if File.exist?(pidfile) + end + + def check_pid + case pid_status(pidfile) + when :running, :not_owned + abort "A server is already running. Check #{pidfile}" + when :dead + File.delete(pidfile) + end + end + + def pid_status(pidfile) + return :exited unless File.exist?(pidfile) + + pid = ::File.read(pidfile).to_i + return :dead if pid.zero? + + Process.kill(0, pid) # check process status + :running + rescue Errno::ESRCH + :dead + rescue Errno::EPERM + :not_owned + end + end +end diff --git a/spec/lib/good_job/daemon_spec.rb b/spec/lib/good_job/daemon_spec.rb new file mode 100644 index 000000000..5bab86d8c --- /dev/null +++ b/spec/lib/good_job/daemon_spec.rb @@ -0,0 +1,39 @@ +require 'rails_helper' + +RSpec.describe GoodJob::Daemon, skip_if_java: true do + let(:pidfile) { Rails.application.root.join('tmp/pidfile.pid') } + let(:daemon) { described_class.new(pidfile: pidfile) } + + before do + FileUtils.mkdir_p Rails.application.root.join('tmp') + allow(Process).to receive(:daemon) + allow(daemon).to receive(:at_exit) + end + + after do + File.delete(pidfile) + end + + describe '#daemonize' do + it 'calls Process.daemon' do + daemon.daemonize + expect(Process).to have_received :daemon + end + + it 'writes a pidfile' do + expect do + daemon.daemonize + end.to change { Pathname.new(pidfile).exist? }.from(false).to(true) + end + + context 'when a pidfile already exists' do + before do + File.open(pidfile, "w") { |f| f.write(Process.pid) } + end + + it 'aborts with a message' do + expect { daemon.daemonize }.to output("A server is already running. Check #{pidfile}\n").to_stderr.and raise_error SystemExit + end + end + end +end