|
| 1 | +#!/usr/bin/env ruby |
| 2 | +# frozen_string_literal: true |
| 3 | + |
| 4 | +require 'open3' |
| 5 | + |
| 6 | +# Configuration Constants |
| 7 | +PASSENGER_STATUS_CMD = 'passenger-status' |
| 8 | +MIN_WORKERS_ALLOWED = 4 |
| 9 | +LAST_USED_THRESHOLD_SECONDS = 10 * 60 # 10 minutes |
| 10 | + |
| 11 | +# Class representing a single Passenger Worker Process |
| 12 | +class WorkerProcess |
| 13 | + attr_reader :pid, :sessions, :processed, :uptime_seconds, :cpu, :memory_mb, :last_used_seconds |
| 14 | + |
| 15 | + def initialize(pid:, sessions:, processed:, uptime_str:, cpu:, memory_str:, last_used_str:) |
| 16 | + @pid = pid |
| 17 | + @sessions = sessions |
| 18 | + @processed = processed |
| 19 | + @uptime_seconds = parse_time(uptime_str) |
| 20 | + @cpu = cpu |
| 21 | + @memory_mb = parse_memory(memory_str) |
| 22 | + @last_used_seconds = parse_time(last_used_str) |
| 23 | + end |
| 24 | + |
| 25 | + # Parses a time string like "16m 52s" into total seconds |
| 26 | + def parse_time(time_str) |
| 27 | + total_seconds = 0 |
| 28 | + # Match patterns like "16m", "52s", etc. |
| 29 | + time_str.scan(/(\d+)m|(\d+)s/) do |min, sec| |
| 30 | + total_seconds += min.to_i * 60 if min |
| 31 | + total_seconds += sec.to_i if sec |
| 32 | + end |
| 33 | + total_seconds |
| 34 | + end |
| 35 | + |
| 36 | + # Parses memory string like "184M", "1.2G", "512K" into integer megabytes |
| 37 | + def parse_memory(mem_str) |
| 38 | + match = mem_str.strip.match(/^([\d.]+)([KMGTP])?$/i) |
| 39 | + return 0 unless match |
| 40 | + |
| 41 | + value = match[1].to_f |
| 42 | + unit = match[2]&.upcase || 'M' |
| 43 | + |
| 44 | + case unit |
| 45 | + when 'K' |
| 46 | + (value / 1024).round(2) |
| 47 | + when 'M' |
| 48 | + value.round(2) |
| 49 | + when 'G' |
| 50 | + (value * 1024).round(2) |
| 51 | + when 'T' |
| 52 | + (value * 1024 * 1024).round(2) |
| 53 | + when 'P' |
| 54 | + (value * 1024 * 1024 * 1024).round(2) |
| 55 | + else |
| 56 | + value.round(2) |
| 57 | + end |
| 58 | + end |
| 59 | + |
| 60 | + def to_s |
| 61 | + "PID: #{@pid}, Last Used: #{@last_used_seconds}s, Memory: #{@memory_mb} MB" |
| 62 | + end |
| 63 | +end |
| 64 | + |
| 65 | +# Class responsible for executing and parsing passenger-status output |
| 66 | +class PassengerStatusParser |
| 67 | + attr_reader :total_processes, :workers |
| 68 | + |
| 69 | + def initialize(command: PASSENGER_STATUS_CMD) |
| 70 | + @command = command |
| 71 | + @total_processes = 0 |
| 72 | + @workers = [] |
| 73 | + end |
| 74 | + |
| 75 | + def execute |
| 76 | + stdout, stderr, status = Open3.capture3(@command) |
| 77 | + |
| 78 | + unless status.success? |
| 79 | + raise "Error executing #{@command}: #{stderr}" |
| 80 | + end |
| 81 | + |
| 82 | + parse(stdout) |
| 83 | + end |
| 84 | + |
| 85 | + private |
| 86 | + def parse(output) |
| 87 | + current_worker_data = {} |
| 88 | + in_app_group = false |
| 89 | + |
| 90 | + output.each_line do |line| |
| 91 | + line = line.strip |
| 92 | + |
| 93 | + # Capture total processes |
| 94 | + if line.start_with?('Processes :') |
| 95 | + @total_processes = line.split(':').last.strip.to_i |
| 96 | + end |
| 97 | + |
| 98 | + # Detect start of Application groups |
| 99 | + if line.start_with?('----------- Application groups -----------') |
| 100 | + in_app_group = true |
| 101 | + next |
| 102 | + end |
| 103 | + |
| 104 | + next unless in_app_group |
| 105 | + |
| 106 | + # Start of a worker entry |
| 107 | + if line.start_with?('* PID:') |
| 108 | + # Save previous worker if exists |
| 109 | + if current_worker_data.any? |
| 110 | + @workers << build_worker(current_worker_data) |
| 111 | + current_worker_data = {} |
| 112 | + end |
| 113 | + |
| 114 | + # Extract PID, Sessions, Processed, Uptime |
| 115 | + pid_match = line.match(/\* PID:\s*(\d+)\s+Sessions:\s*(\d+)\s+Processed:\s*(\d+)\s+Uptime:\s*([\dm\s]+s)/) |
| 116 | + if pid_match |
| 117 | + current_worker_data[:pid] = pid_match[1].to_i |
| 118 | + current_worker_data[:sessions] = pid_match[2].to_i |
| 119 | + current_worker_data[:processed] = pid_match[3].to_i |
| 120 | + current_worker_data[:uptime_str] = pid_match[4].strip |
| 121 | + end |
| 122 | + elsif line.start_with?('CPU:') |
| 123 | + # Extract CPU and Memory |
| 124 | + cpu_match = line.match(/CPU:\s*([\d.]+)%/) |
| 125 | + memory_match = line.match(/Memory\s*:\s*([\d.]+[KMGTP]?)/) |
| 126 | + current_worker_data[:cpu] = cpu_match[1].to_f if cpu_match |
| 127 | + current_worker_data[:memory_str] = memory_match[1] if memory_match |
| 128 | + elsif line.start_with?('Last used:') |
| 129 | + # Extract Last used |
| 130 | + last_used_match = line.match(/Last used:\s*([\dm\s]+s)\s*(?:ago|ag)?/) |
| 131 | + if last_used_match |
| 132 | + current_worker_data[:last_used_str] = last_used_match[1].strip |
| 133 | + end |
| 134 | + end |
| 135 | + end |
| 136 | + |
| 137 | + # Add the last worker if exists |
| 138 | + if current_worker_data.any? |
| 139 | + @workers << build_worker(current_worker_data) |
| 140 | + end |
| 141 | + end |
| 142 | + |
| 143 | + def build_worker(data) |
| 144 | + WorkerProcess.new( |
| 145 | + pid: data[:pid], |
| 146 | + sessions: data[:sessions], |
| 147 | + processed: data[:processed], |
| 148 | + uptime_str: data[:uptime_str], |
| 149 | + cpu: data[:cpu], |
| 150 | + memory_str: data[:memory_str], |
| 151 | + last_used_str: data[:last_used_str] |
| 152 | + ) |
| 153 | + end |
| 154 | +end |
| 155 | + |
| 156 | +# Class responsible for managing Passenger Workers |
| 157 | +class PassengerWorkerManager |
| 158 | + def initialize(parser: PassengerStatusParser.new) |
| 159 | + @parser = parser |
| 160 | + end |
| 161 | + |
| 162 | + def run |
| 163 | + begin |
| 164 | + @parser.execute |
| 165 | + rescue => e |
| 166 | + puts e.message |
| 167 | + exit 1 |
| 168 | + end |
| 169 | + |
| 170 | + total_processes = @parser.total_processes |
| 171 | + workers = @parser.workers |
| 172 | + |
| 173 | + puts "Total Processes: #{total_processes}" |
| 174 | + puts "Total Workers: #{workers.size}" |
| 175 | + |
| 176 | + if total_processes > MIN_WORKERS_ALLOWED |
| 177 | + puts "Number of processes (#{total_processes}) exceeds the minimum allowed (#{MIN_WORKERS_ALLOWED})." |
| 178 | + |
| 179 | + worker_to_kill = find_worker_to_kill(workers) |
| 180 | + |
| 181 | + if worker_to_kill |
| 182 | + pid = worker_to_kill.pid |
| 183 | + last_used_seconds = worker_to_kill.last_used_seconds |
| 184 | + last_used_minutes = (last_used_seconds / 60).to_i |
| 185 | + last_used_seconds_remainder = last_used_seconds % 60 |
| 186 | + |
| 187 | + puts "Killing worker PID #{pid} with last used time of #{last_used_minutes}m #{last_used_seconds_remainder}s and memory: #{worker_to_kill.memory_mb} MB." |
| 188 | + |
| 189 | + kill_worker(pid) |
| 190 | + else |
| 191 | + puts "No workers have been idle for more than #{LAST_USED_THRESHOLD_SECONDS / 60} minutes." |
| 192 | + end |
| 193 | + else |
| 194 | + puts "Number of processes (#{total_processes}) is under (#{MIN_WORKERS_ALLOWED}). No action needed." |
| 195 | + end |
| 196 | + end |
| 197 | + |
| 198 | + private |
| 199 | + def find_worker_to_kill(workers) |
| 200 | + eligible_workers = workers.select { |w| w.last_used_seconds > LAST_USED_THRESHOLD_SECONDS } |
| 201 | + |
| 202 | + return nil if eligible_workers.empty? |
| 203 | + |
| 204 | + # Find the worker with the maximum last used time |
| 205 | + eligible_workers.max_by { |w| w.last_used_seconds } |
| 206 | + end |
| 207 | + |
| 208 | + def kill_worker(pid) |
| 209 | + Process.kill('TERM', pid) |
| 210 | + puts "Successfully sent TERM signal to PID #{pid}." |
| 211 | + rescue Errno::ESRCH |
| 212 | + puts "Process with PID #{pid} does not exist." |
| 213 | + rescue Errno::EPERM |
| 214 | + puts "Insufficient permissions to kill PID #{pid}." |
| 215 | + rescue => e |
| 216 | + puts "Failed to kill PID #{pid}: #{e.message}" |
| 217 | + end |
| 218 | +end |
| 219 | + |
| 220 | +manager = PassengerWorkerManager.new |
| 221 | +manager.run |
0 commit comments