process_bot 0.1.21 → 0.1.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f9caad47714b9ab413bc33e85beb54b250455b6831ace86d058e6e63e84939bb
4
- data.tar.gz: f5d294e5d78cf4bafa60d4ddca38cea9887310ed3cc517d0fbe91ff95673c5b4
3
+ metadata.gz: f3af0757fbd8fdac9c26eecdd368c222da9a659d491c64ee16d5d7fa0b1235c3
4
+ data.tar.gz: 7faf33b03baf64de58f27086351d553f0cbf777dc78b360fbcda2d9b82aecc04
5
5
  SHA512:
6
- metadata.gz: ec5cdb7e07944681062b07f44408c0eb17962f4667a8215eead66a5b62ba6d0c78ffb521c431c94199fcff597bee5e5755df540687e79396cbb53df1daca5034
7
- data.tar.gz: 3e21dba2ec87ef787ce3c4bf3b1d6f81273896862a04807ee48f81cc86b8e769712c1de043106c9e64cb16b6ac47f52f0a99d025614c0861ba60f4728fa1fb97
6
+ metadata.gz: 36f931dc6159ac509769c9e37ca8cf42972f6035ba58f1f311b105461acc7109a8a205690366b77fdb99cdd49c0ddfded4e2bcaec69c2357f32117776b5d0d35
7
+ data.tar.gz: 98e49815e07c19d8bc6e374577cd83236ded5f78a295fbfbf6c662317ede6403a33254b55edd564030d3c8617fa124f0fa77d72d7a18b71d3e05f60a5e932ab8
data/CHANGELOG.md CHANGED
@@ -5,6 +5,8 @@
5
5
  - Bump version to 0.1.20.
6
6
  - Flush log output immediately so Capistrano can stream it.
7
7
  - Bump version to 0.1.21.
8
+ - Add optional Sidekiq restart overlap and a new ProcessBot restart command.
9
+ - Guard stop-related process scanning when subprocess PID/PGID is unavailable and fail stop loudly.
8
10
 
9
11
  ## [0.1.0] - 2022-04-03
10
12
 
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- process_bot (0.1.21)
4
+ process_bot (0.1.23)
5
5
  knjrbfw (>= 0.0.116)
6
6
  pry
7
7
  rake
@@ -61,7 +61,7 @@ GEM
61
61
  rspec-mocks (3.13.7)
62
62
  diff-lcs (>= 1.2.0, < 2.0)
63
63
  rspec-support (~> 3.13.0)
64
- rspec-support (3.13.6)
64
+ rspec-support (3.13.7)
65
65
  rubocop (1.82.1)
66
66
  json (~> 2.3)
67
67
  language_server-protocol (~> 3.17.0.2)
data/README.md CHANGED
@@ -75,6 +75,22 @@ bundle exec process_bot --command start --log true --log-file-path /var/log/proc
75
75
  Use `process_bot:sidekiq:graceful` to wait for running jobs, and
76
76
  `process_bot:sidekiq:graceful_no_wait` to return immediately while Sidekiq drains.
77
77
 
78
+ ### Overlapping restarts
79
+
80
+ You can restart Sidekiq while the old process drains by enabling overlap on the ProcessBot instance:
81
+
82
+ ```ruby
83
+ set :sidekiq_restart_overlap, true
84
+ ```
85
+
86
+ When enabled, `process_bot:sidekiq:restart` will use the overlap behavior.
87
+
88
+ Or when running ProcessBot directly:
89
+
90
+ ```bash
91
+ bundle exec process_bot --command restart --sidekiq-restart-overlap true
92
+ ```
93
+
78
94
  ### Capistrano logging
79
95
 
80
96
  ProcessBot logging is enabled by default in the Capistrano integration.
@@ -13,6 +13,7 @@ namespace :load do
13
13
  set :sidekiq_processes, 1
14
14
  set :sidekiq_options_per_process, nil
15
15
  set :sidekiq_user, nil
16
+ set :sidekiq_restart_overlap, nil
16
17
  set :process_bot_log, true
17
18
  # Rbenv, Chruby, and RVM integration
18
19
  set :rbenv_map_bins, fetch(:rbenv_map_bins).to_a + ["sidekiq", "sidekiqctl"]
@@ -118,8 +119,27 @@ namespace :process_bot do
118
119
 
119
120
  desc "Restart Sidekiq and ProcessBot"
120
121
  task :restart do
121
- invoke! "process_bot:sidekiq:stop"
122
- invoke! "process_bot:sidekiq:start"
122
+ if fetch(:sidekiq_restart_overlap, nil)
123
+ on roles fetch(:sidekiq_roles) do |role|
124
+ git_plugin.switch_user(role) do
125
+ running_processes = git_plugin.running_process_bot_processes
126
+
127
+ if running_processes.any?
128
+ running_processes.each do |process_bot_process|
129
+ git_plugin.process_bot_command(process_bot_process, :restart)
130
+ end
131
+ else
132
+ fetch(:sidekiq_processes).times do |idx|
133
+ puts "Starting Sidekiq with ProcessBot #{idx}"
134
+ git_plugin.start_sidekiq(idx)
135
+ end
136
+ end
137
+ end
138
+ end
139
+ else
140
+ invoke! "process_bot:sidekiq:stop"
141
+ invoke! "process_bot:sidekiq:start"
142
+ end
123
143
  end
124
144
  end
125
145
  end
@@ -167,6 +167,7 @@ module ProcessBot::Capistrano::SidekiqHelpers # rubocop:disable Metrics/ModuleLe
167
167
  args += ["--sidekiq-queues", Array(fetch(:sidekiq_queue)).join(",")] if fetch(:sidekiq_queue)
168
168
  args += ["--sidekiq-config", fetch(:sidekiq_config)] if fetch(:sidekiq_config)
169
169
  args += ["--sidekiq-concurrency", fetch(:sidekiq_concurrency)] if fetch(:sidekiq_concurrency)
170
+ args += ["--sidekiq-restart-overlap", fetch(:sidekiq_restart_overlap)] unless fetch(:sidekiq_restart_overlap, nil).nil?
170
171
  if (process_options = fetch(:sidekiq_options_per_process))
171
172
  args += process_options[idx]
172
173
  end
@@ -84,7 +84,7 @@ class ProcessBot::ControlSocket
84
84
  command = JSON.parse(data)
85
85
  command_type = command.fetch("command")
86
86
 
87
- if command_type == "graceful" || command_type == "graceful_no_wait" || command_type == "stop"
87
+ if command_type == "graceful" || command_type == "graceful_no_wait" || command_type == "restart" || command_type == "stop"
88
88
  begin
89
89
  unless process.accept_control_commands?
90
90
  client.puts(JSON.generate(type: "error", message: "ProcessBot is shutting down", backtrace: Thread.current.backtrace))
@@ -141,8 +141,8 @@ class ProcessBot::Process::Handlers::Sidekiq
141
141
  command
142
142
  end
143
143
 
144
- def graceful(**_args)
145
- process.set_stopped
144
+ def graceful(stop_process_bot: true, **_args)
145
+ process.set_stopped if stop_process_bot
146
146
 
147
147
  return unless ensure_current_pid?
148
148
 
@@ -152,8 +152,8 @@ class ProcessBot::Process::Handlers::Sidekiq
152
152
  wait_for_no_jobs_and_stop_sidekiq
153
153
  end
154
154
 
155
- def graceful_no_wait(**_args)
156
- process.set_stopped
155
+ def graceful_no_wait(stop_process_bot: true, **_args)
156
+ process.set_stopped if stop_process_bot
157
157
 
158
158
  return unless ensure_current_pid?
159
159
 
@@ -65,7 +65,11 @@ class ProcessBot::Process::Runner
65
65
  end
66
66
 
67
67
  def subprocess_pgid
68
- @subprocess_pgid ||= Process.getpgid(subprocess_pid)
68
+ return @subprocess_pgid if instance_variable_defined?(:@subprocess_pgid)
69
+
70
+ @subprocess_pgid = Process.getpgid(subprocess_pid) if subprocess_pid
71
+ rescue Errno::ESRCH
72
+ @subprocess_pgid = nil
69
73
  end
70
74
 
71
75
  def sidekiq_app_name
@@ -74,6 +78,8 @@ class ProcessBot::Process::Runner
74
78
 
75
79
  def related_processes
76
80
  related_processes = []
81
+ process_group_id = subprocess_pgid
82
+ return related_processes unless process_group_id
77
83
 
78
84
  Knj::Unix_proc.list do |process|
79
85
  begin
@@ -82,7 +88,7 @@ class ProcessBot::Process::Runner
82
88
  # Process no longer running
83
89
  end
84
90
 
85
- related_processes << process if subprocess_pgid == process_pgid
91
+ related_processes << process if process_group_id == process_pgid
86
92
  end
87
93
 
88
94
  related_processes
@@ -90,6 +96,8 @@ class ProcessBot::Process::Runner
90
96
 
91
97
  def related_sidekiq_processes
92
98
  related_sidekiq_processes = []
99
+ process_group_id = subprocess_pgid
100
+ return related_sidekiq_processes unless process_group_id
93
101
 
94
102
  Knj::Unix_proc.list("grep" => "sidekiq") do |process|
95
103
  cmd = process.data.fetch("cmd")
@@ -103,7 +111,7 @@ class ProcessBot::Process::Runner
103
111
  # Process no longer running
104
112
  end
105
113
 
106
- related_sidekiq_processes << process if subprocess_pgid == sidekiq_pgid
114
+ related_sidekiq_processes << process if process_group_id == sidekiq_pgid
107
115
  end
108
116
  end
109
117
 
@@ -111,6 +119,8 @@ class ProcessBot::Process::Runner
111
119
  end
112
120
 
113
121
  def stop_related_processes
122
+ ensure_subprocess_pgid_for_stop!
123
+
114
124
  loop do
115
125
  processes = related_processes
116
126
 
@@ -128,6 +138,12 @@ class ProcessBot::Process::Runner
128
138
  end
129
139
  end
130
140
 
141
+ def ensure_subprocess_pgid_for_stop!
142
+ return if subprocess_pgid
143
+
144
+ raise "Unable to stop related processes because subprocess PGID could not be resolved (subprocess PID: #{subprocess_pid.inspect})"
145
+ end
146
+
131
147
  def find_sidekiq_pid
132
148
  Thread.new do
133
149
  wait_for_sidekiq_pid
@@ -0,0 +1,28 @@
1
+ class ProcessBot::Process::RunnerInstance
2
+ attr_reader :runner, :thread
3
+
4
+ def initialize(runner:, event_queue:, logger:)
5
+ @runner = runner
6
+ @event_queue = event_queue
7
+ @logger = logger
8
+ end
9
+
10
+ def start
11
+ @thread = Thread.new do
12
+ runner.run
13
+ event_queue << {type: :stopped, runner_instance: self}
14
+ rescue => e # rubocop:disable Style/RescueStandardError
15
+ logger.error e.message
16
+ logger.error e.backtrace
17
+ event_queue << {type: :error, runner_instance: self, error: e}
18
+ end
19
+ end
20
+
21
+ def running?
22
+ runner.running?
23
+ end
24
+
25
+ private
26
+
27
+ attr_reader :event_queue, :logger
28
+ end
@@ -12,6 +12,7 @@ class ProcessBot::Process
12
12
 
13
13
  autoload :Handlers, "#{__dir__}/process/handlers"
14
14
  autoload :Runner, "#{__dir__}/process/runner"
15
+ autoload :RunnerInstance, "#{__dir__}/process/runner_instance"
15
16
 
16
17
  attr_reader :control_command_monitor, :current_pid, :current_process_title, :options, :port, :stopped
17
18
 
@@ -21,6 +22,9 @@ class ProcessBot::Process
21
22
  @accept_control_commands = true
22
23
  @control_command_monitor = Monitor.new
23
24
  @control_commands_in_flight = 0
25
+ @runner_events = Queue.new
26
+ @runner_instances = []
27
+ @runner_monitor = Monitor.new
24
28
 
25
29
  options.events.connect(:on_process_started, &method(:on_process_started)) # rubocop:disable Performance/MethodObjectAsBlock
26
30
  options.events.connect(:on_socket_opened, &method(:on_socket_opened)) # rubocop:disable Performance/MethodObjectAsBlock
@@ -34,7 +38,7 @@ class ProcessBot::Process
34
38
  if command == "start"
35
39
  logger.logs "Starting process"
36
40
  start
37
- elsif command == "graceful" || command == "graceful_no_wait" || command == "stop"
41
+ elsif command == "graceful" || command == "graceful_no_wait" || command == "restart" || command == "stop"
38
42
  send_control_command(command)
39
43
  else
40
44
  raise "Unknown command: #{command}"
@@ -85,18 +89,32 @@ class ProcessBot::Process
85
89
 
86
90
  def start
87
91
  start_control_socket
92
+ start_runner_instance
88
93
 
89
94
  loop do
90
- run
95
+ runner_event = runner_events.pop
96
+ handle_runner_event(runner_event)
91
97
 
92
- if stopped
93
- stop_accepting_control_commands
94
- wait_for_control_commands
95
- break
98
+ next unless stopped && runner_instances.empty?
99
+
100
+ stop_accepting_control_commands
101
+ wait_for_control_commands
102
+ break
103
+ end
104
+ end
105
+
106
+ def restart(**args)
107
+ logger.logs "Restart process"
108
+
109
+ if handler_name == "sidekiq"
110
+ if restart_overlap?(args)
111
+ handler_instance.graceful_no_wait(stop_process_bot: false)
112
+ start_runner_instance
96
113
  else
97
- logger.logs "Process stopped - starting again after 1 sec"
98
- sleep 1
114
+ handler_instance.graceful(stop_process_bot: false)
99
115
  end
116
+ else
117
+ handler_instance.stop
100
118
  end
101
119
  end
102
120
 
@@ -107,7 +125,7 @@ class ProcessBot::Process
107
125
  end
108
126
 
109
127
  def run
110
- runner.run
128
+ start_runner_instance
111
129
  end
112
130
 
113
131
  def send_control_command(command, **command_options)
@@ -119,13 +137,7 @@ class ProcessBot::Process
119
137
  end
120
138
 
121
139
  def runner
122
- @runner ||= ProcessBot::Process::Runner.new(
123
- command: handler_instance.start_command,
124
- handler_name: handler_name,
125
- handler_instance: handler_instance,
126
- logger: logger,
127
- options: options
128
- )
140
+ current_runner_instance&.runner || @runner ||= build_runner
129
141
  end
130
142
 
131
143
  def update_process_title
@@ -164,4 +176,94 @@ class ProcessBot::Process
164
176
  @control_commands_in_flight
165
177
  end
166
178
  end
179
+
180
+ def build_runner
181
+ ProcessBot::Process::Runner.new(
182
+ command: handler_instance.start_command,
183
+ handler_name: handler_name,
184
+ handler_instance: handler_instance,
185
+ logger: logger,
186
+ options: options
187
+ )
188
+ end
189
+
190
+ def start_runner_instance
191
+ runner_instance = ProcessBot::Process::RunnerInstance.new(
192
+ runner: build_runner,
193
+ event_queue: runner_events,
194
+ logger: logger
195
+ )
196
+
197
+ track_runner_instance(runner_instance)
198
+ @current_runner_instance = runner_instance
199
+ @runner = runner_instance.runner
200
+ runner_instance.start
201
+ end
202
+
203
+ def handle_runner_event(runner_event)
204
+ runner_instance = runner_event.fetch(:runner_instance)
205
+ remove_runner_instance(runner_instance)
206
+ log_runner_event_error(runner_event)
207
+ clear_current_runner(runner_instance)
208
+ restart_runner_if_needed(runner_instance)
209
+ end
210
+
211
+ def runner_instances
212
+ runner_monitor.synchronize do
213
+ @runner_instances.dup
214
+ end
215
+ end
216
+
217
+ def track_runner_instance(runner_instance)
218
+ runner_monitor.synchronize do
219
+ @runner_instances << runner_instance
220
+ end
221
+ end
222
+
223
+ def remove_runner_instance(runner_instance)
224
+ runner_monitor.synchronize do
225
+ @runner_instances.delete(runner_instance)
226
+ end
227
+ end
228
+
229
+ def restart_overlap?(command_options = {})
230
+ value = if command_options.key?(:sidekiq_restart_overlap)
231
+ command_options[:sidekiq_restart_overlap]
232
+ else
233
+ options[:sidekiq_restart_overlap]
234
+ end
235
+ return false if value.nil?
236
+ return value if value == true || value == false
237
+
238
+ normalized = value.to_s.strip.downcase
239
+ return false if normalized == "false" || normalized == "0" || normalized == ""
240
+
241
+ true
242
+ end
243
+
244
+ def log_runner_event_error(runner_event)
245
+ return unless runner_event.fetch(:type) == :error
246
+
247
+ logger.error "Process runner crashed: #{runner_event.fetch(:error)}"
248
+ end
249
+
250
+ def clear_current_runner(runner_instance)
251
+ return unless runner_instance == current_runner_instance
252
+
253
+ @current_runner_instance = nil
254
+ @runner = nil
255
+ end
256
+
257
+ def restart_runner_if_needed(runner_instance)
258
+ return if stopped
259
+ return unless runner_instance == current_runner_instance || current_runner_instance.nil?
260
+
261
+ logger.logs "Process stopped - starting again after 1 sec"
262
+ sleep 1
263
+ start_runner_instance
264
+ end
265
+
266
+ private
267
+
268
+ attr_reader :current_runner_instance, :runner_events, :runner_monitor
167
269
  end
@@ -1,3 +1,3 @@
1
1
  module ProcessBot
2
- VERSION = "0.1.21".freeze
2
+ VERSION = "0.1.23".freeze
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: process_bot
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.21
4
+ version: 0.1.23
5
5
  platform: ruby
6
6
  authors:
7
7
  - kaspernj
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-01-13 00:00:00.000000000 Z
11
+ date: 2026-02-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: knjrbfw
@@ -173,6 +173,7 @@ files:
173
173
  - lib/process_bot/process/handlers/custom.rb
174
174
  - lib/process_bot/process/handlers/sidekiq.rb
175
175
  - lib/process_bot/process/runner.rb
176
+ - lib/process_bot/process/runner_instance.rb
176
177
  - lib/process_bot/version.rb
177
178
  - peak_flow.yml
178
179
  - process_bot.gemspec