diff --git a/lib/prism/cli.rb b/lib/prism/cli.rb index c47a564..a482fd8 100644 --- a/lib/prism/cli.rb +++ b/lib/prism/cli.rb @@ -206,6 +206,59 @@ module Prism worker = Jobs::Worker.new( queue, queue_name: options[:queue_name], + error_handler: lambda do |error, job| + warn "[prism jobs] job #{job[:id]} (#{job[:job_class]}) failed: #{error.message}" + end + ) + + running = true + trap('INT') { running = false } + trap('TERM') { running = false } + + cycles = 0 + loop do + processed = worker.work_off + out.puts "[prism jobs] processed #{processed} job(s)" if options[:verbose] && processed.positive? + cycles += 1 + break if options[:once] + break if options[:max_cycles] && cycles >= options[:max_cycles] + break unless running + sleep(options[:interval]) + break unless running + end + cycles + ensure + remove_pidfile(pidfile) if pidfile + end + + # Refuses to start a second daemon against the same pidfile while a + # process recorded in it is still alive (mirrors the classic + # pidfile-lock pattern used by unicorn/sidekiq); a pidfile left behind + # by a process that crashed without cleaning up (stale pid, nothing + # answers Process.kill(0, ...)) is silently reclaimed instead of + # blocking every future start forever. + def write_pidfile(pidfile) + if File.exist?(pidfile) + existing_pid = File.read(pidfile).strip.to_i + if existing_pid.positive? && process_alive?(existing_pid) + raise UsageError, "jobs worker already running (pid #{existing_pid}, pidfile #{pidfile})" + end + end + FileUtils.mkdir_p(File.dirname(pidfile)) + File.write(pidfile, Process.pid.to_s) + end + + def remove_pidfile(pidfile) + File.delete(pidfile) if File.exist?(pidfile) && File.read(pidfile).strip.to_i == Process.pid + end + + def process_alive?(pid) + Process.kill(0, pid) + true + rescue Errno::ESRCH + false + rescue Errno::EPERM + true end def new_app(name) @@ -253,6 +306,145 @@ module Prism end end + def jobs(rest) + subcommand, *args = rest + case subcommand + when 'work' then jobs_work(args) + else raise UsageError, "unknown jobs subcommand #{subcommand.inspect}" + end + end + + def parse_jobs_work_options(args) + options = { file: 'db/jobs.pstore', interval: 1.0, queue_name: nil, once: false, + max_cycles: nil, verbose: false, require: [], pidfile: nil } + args.each do |arg| + case arg + when '--once' then options[:once] = true + when '--verbose' then options[:verbose] = true + when /\A--file=(.+)\z/ then options[:file] = Regexp.last_match(1) + when /\A--interval=(.+)\z/ then options[:interval] = Regexp.last_match(1).to_f + when /\A--queue=(.+)\z/ then options[:queue_name] = Regexp.last_match(1) + when /\A--max-cycles=(\d+)\z/ then options[:max_cycles] = Regexp.last_match(1).to_i + when /\A--require=(.+)\z/ then options[:require] << Regexp.last_match(1) + when /\A--pidfile=(.+)\z/ then options[:pidfile] = Regexp.last_match(1) + else raise UsageError, "unknown option #{arg.inspect}" + end + end + options + end + + def parse_routes_options(args) + options = { require: [], app: nil } + args.each do |arg| + case arg + when /\A--require=(.+)\z/ then options[:require] << Regexp.last_match(1) + when /\A--app=(.+)\z/ then options[:app] = Regexp.last_match(1) + else raise UsageError, "unknown option #{arg.inspect}" + end + end + options + end + + # `--app` picks by name when a project loads more than one + # Prism::Application subclass (e.g. a host app plus a mounted engine). + # Auto-detection deliberately looks only at subclasses newly defined by + # THIS call's --require, not at Application.subclasses as a whole: a + # Ruby class is never garbage-collected just because its constant gets + # undefined, so in a long-lived process (the test suite, an irb/console + # session reloading code) every Application subclass ever defined stays + # in that list forever - scanning it whole would make auto-detection + # increasingly wrong the longer the process lives, not just after a + # fresh `ruby bin/prism routes --require=app.rb` (where it happens to + # equal "the one class app.rb just defined" anyway). + def resolve_app_class(explicit_name, newly_loaded) + return Object.const_get(explicit_name) if explicit_name + raise UsageError, 'no Prism::Application subclass loaded (pass --require=FILE or --app=ClassName)' if newly_loaded.empty? + if newly_loaded.size > 1 + names = newly_loaded.map(&:name).join(', ') + raise UsageError, "multiple Prism::Application subclasses loaded (#{names}) - pick one with --app=ClassName" + end + newly_loaded.first + end + + def print_routes(router, out) + rows = router.routes.map do |route| + [route.method, route.pattern, route_target(route), route.name ? "#{route.name}_path" : ''] + end + header = %w[VERB PATH TO NAME] + widths = header.each_index.map { |i| ([header[i].length] + rows.map { |row| row[i].length }).max } + + out.puts format_row(header, widths) + rows.each { |row| out.puts format_row(row, widths) } + + router.mounts.each do |prefix, mounted_app| + out.puts "MOUNT #{prefix} -> #{mounted_app.class}" + end + nil + end + + def route_target(route) + route.controller_route? ? "#{route.controller_name}##{route.action_name}" : '(block)' + end + + def format_row(row, widths) + row.each_index.map { |i| row[i].ljust(widths[i]) }.join(' ').rstrip + end + + def parse_console_options(args) + options = { require: [], prompt: 'prism> ' } + args.each do |arg| + case arg + when /\A--require=(.+)\z/ then options[:require] << Regexp.last_match(1) + when /\A--prompt=(.+)\z/ then options[:prompt] = Regexp.last_match(1) + else raise UsageError, "unknown option #{arg.inspect}" + end + end + options + end + + def parse_seed_options(args) + options = { require: [], file: 'db/seeds.rb' } + args.each do |arg| + case arg + when /\A--require=(.+)\z/ then options[:require] << Regexp.last_match(1) + when /\A--file=(.+)\z/ then options[:file] = Regexp.last_match(1) + else raise UsageError, "unknown option #{arg.inspect}" + end + end + options + end + + def parse_db_options(args) + options = { database: 'db/development.pstore', migrations: 'db/migrate', step: 1 } + args.each do |arg| + case arg + when /\A--database=(.+)\z/ then options[:database] = Regexp.last_match(1) + when /\A--migrations=(.+)\z/ then options[:migrations] = Regexp.last_match(1) + when /\A--step=(\d+)\z/ then options[:step] = Regexp.last_match(1).to_i + else raise UsageError, "unknown option #{arg.inspect}" + end + end + options + end + + def build_migrator(options) + store = Orm::Store.new(File.expand_path(options[:database], @root)) + Orm::Migrator.new(store, File.expand_path(options[:migrations], @root)) + end + + def parse_assets_precompile_options(args) + options = {} + args.each do |arg| + case arg + when /\A--source=(.+)\z/ then options[:source] = Regexp.last_match(1) + when /\A--output=(.+)\z/ then options[:output] = Regexp.last_match(1) + else raise UsageError, "unknown option #{arg.inspect}" + end + end + raise UsageError, 'usage: prism assets:precompile --source=DIR --output=DIR' unless options[:source] && options[:output] + options + end + def next_migration_version version = Time.now.utc.strftime('%Y%m%d%H%M%S') version = (version.to_i + 1).to_s while File.exist?(File.join(@root, 'db', 'migrate')) &&