Skip to content

Commit 1d47c76

Browse files
committed
Add a script for cleaning passenger workers
1 parent ef2e7b7 commit 1d47c76

File tree

1 file changed

+221
-0
lines changed

1 file changed

+221
-0
lines changed

script/passenger_worker_cleaner.rb

+221
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
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

Comments
 (0)