gritz-core 0.2.0 → 0.4.0

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.
@@ -0,0 +1,381 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "fileutils"
5
+ require "tempfile"
6
+
7
+ module Gritz
8
+ module Supervisor
9
+ # Keeps process ownership and probes stable while fresh master interpreters replace each other.
10
+ # @api private
11
+ class Launcher
12
+ Generation = Struct.new(:pid, :channel, :token, :role, :metadata, :snapshot, :deadline,
13
+ :kill_at, :kill_sent, :exit_status, :group_reaped, :identities, keyword_init: true)
14
+ SIGNALS = %w[TERM INT QUIT USR1 USR2 TTIN TTOU HUP CHLD].freeze
15
+
16
+ def self.enable_subreaper!
17
+ return unless RUBY_PLATFORM.include?("linux")
18
+
19
+ require "fiddle"
20
+ function = Fiddle::Function.new(Fiddle.dlopen(nil)["prctl"],
21
+ [Fiddle::TYPE_INT, *Array.new(4, Fiddle::TYPE_LONG)], Fiddle::TYPE_INT)
22
+ raise "Cannot enable child process reaping" unless function.call(36, 1, 0, 0, 0).zero?
23
+ end
24
+
25
+ def initialize(command:, logger:, env: ENV, stdout: $stdout, stderr: $stderr, status_io: nil, startup_timeout: 60)
26
+ @command = command
27
+ @logger = logger
28
+ @env = env
29
+ @stdout = stdout
30
+ @stderr = stderr
31
+ @observer = StatusChannel.new(status_io) if status_io
32
+ @startup_timeout = startup_timeout
33
+ @generations = {}
34
+ @serial = 0
35
+ @metrics = Metrics::Aggregator.new
36
+ @exit_code = 0
37
+ end
38
+
39
+ def run
40
+ self.class.enable_subreaper!
41
+ @signals = SignalQueue.new(signals: SIGNALS)
42
+ spawn_generation
43
+ loop do
44
+ @signals.drain.each { |name| handle_signal(name) }
45
+ # Reading a reexec request can add a generation, so iterate a stable snapshot.
46
+ @generations.values.each do |generation| # rubocop:disable Style/HashEachMethods
47
+ read_generation(generation)
48
+ reap_generation(generation)
49
+ end
50
+ update_lifecycle
51
+ @admin&.poll
52
+ @observer&.write(status)
53
+ @observer&.flush
54
+ break if @stopping && @generations.empty?
55
+
56
+ ios = [@signals.io, *@generations.values.reject { |generation| generation.channel.closed? }.map { |generation| generation.channel.io },
57
+ *@admin&.ios].compact
58
+ IO.select(ios, nil, nil, 0.02)
59
+ end
60
+ @exit_code
61
+ rescue StandardError => e
62
+ @logger.error("Launcher failed: #{e.message}")
63
+ 1
64
+ ensure
65
+ cleanup
66
+ end
67
+
68
+ def status
69
+ active = @active&.snapshot || {}
70
+ pools = @generations.values.select { |generation| %i[active retiring].include?(generation.role) || (!@active && generation == @candidate) }
71
+ workers = pools.flat_map do |generation|
72
+ generation.snapshot&.fetch(:workers, [])&.map do |worker|
73
+ worker.merge(master_pid: generation.pid, retiring: generation.role == :retiring || worker[:retiring] == true)
74
+ end || []
75
+ end
76
+ state = if @stopping
77
+ "draining"
78
+ elsif @active
79
+ "running"
80
+ else
81
+ "starting"
82
+ end
83
+ active.merge(owner_pid: Process.pid, pid: (@active || @candidate)&.pid, state: state,
84
+ workers: workers, ready_workers: ready_workers(@active), admin_address: @admin&.address,
85
+ masters: @generations.values.map { |generation| { pid: generation.pid, state: generation.role.to_s } }, reexec: @reexec)
86
+ end
87
+
88
+ private
89
+
90
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
91
+
92
+ def spawn_generation
93
+ parent, child = UNIXSocket.pair
94
+ pid = Process.spawn(@env.to_h.merge("GRITZ_INTERNAL_OWNER_FD" => "3"), *@command,
95
+ 3 => child, out: @stdout, err: @stderr, pgroup: true)
96
+ @serial += 1
97
+ @candidate = Generation.new(pid: pid, channel: StatusChannel.new(parent), token: @serial, role: :candidate,
98
+ deadline: monotonic + @startup_timeout, identities: {})
99
+ @generations[pid] = @candidate
100
+ @reexec = { state: "starting", pid: pid } if @active
101
+ rescue StandardError
102
+ parent&.close
103
+ raise
104
+ ensure
105
+ child&.close
106
+ end
107
+
108
+ def read_generation(generation)
109
+ generation.channel.read.each do |row|
110
+ next unless row[:pid] == generation.pid
111
+
112
+ case row[:type]
113
+ when "configured" then configure_generation(generation, row)
114
+ when "status"
115
+ generation.snapshot = row.except(:type)
116
+ live_pids = row.fetch(:workers, []).map { |worker| worker[:pid] }
117
+ generation.identities.delete_if do |pid, key|
118
+ next false if live_pids.include?(pid)
119
+
120
+ @metrics.forget(key)
121
+ true
122
+ end
123
+ when "reexec" then request_reexec if generation == @active
124
+ when "restart" then @metrics.record_restart(reason: row[:reason])
125
+ when "metrics"
126
+ previous = generation.identities[row[:worker_pid]]
127
+ key = [generation.token, row[:worker_pid], row[:worker_started_at]].freeze
128
+ @metrics.forget(previous) if previous && previous != key
129
+ generation.identities[row[:worker_pid]] = key
130
+ @metrics.apply(key, row.slice(:seq, :delta))
131
+ end
132
+ end
133
+ rescue ConfigurationError, ArgumentError, SystemCallError => e
134
+ fail_candidate(e.message) if generation == @candidate
135
+ @logger.error("Master #{generation.pid}: #{e.message}")
136
+ end
137
+
138
+ def configure_generation(generation, metadata)
139
+ return if generation.metadata || @stopping
140
+
141
+ %i[admin_bind bind pid_file].each do |field|
142
+ raise ConfigurationError, "Missing master #{field}" unless metadata[field].is_a?(String)
143
+ end
144
+ %i[workers min_ready_workers].each do |field|
145
+ raise ConfigurationError, "Invalid master #{field}" unless metadata[field].is_a?(Integer) && metadata[field] >= 0
146
+ end
147
+ %i[reexec_timeout drain_delay shutdown_timeout].each do |field|
148
+ value = metadata[field]
149
+ raise ConfigurationError, "Invalid master #{field}" unless value.is_a?(Numeric) && value.finite? && value >= 0
150
+ end
151
+ if @metadata
152
+ %i[admin_bind bind pid_file].each do |field|
153
+ raise ConfigurationError, "USR2 cannot change #{field}" unless metadata[field] == @metadata[field]
154
+ end
155
+ else
156
+ @metadata = metadata
157
+ @admin = AdminServer.new(bind: metadata[:admin_bind], status: -> { status }, ready: -> { ready? },
158
+ metrics: -> { @metrics.render(workers: status[:workers]) }, logger: @logger)
159
+ lock_pid_file(metadata[:pid_file]) unless metadata[:pid_file].empty?
160
+ end
161
+ generation.metadata = metadata
162
+ generation.deadline = [generation.deadline, monotonic + metadata[:reexec_timeout]].min
163
+ end
164
+
165
+ def ready_workers(generation)
166
+ return 0 unless generation&.snapshot&.dig(:state) == "running"
167
+
168
+ generation.snapshot.fetch(:workers, []).count do |worker|
169
+ worker[:state] == "ready" && worker[:healthy] != false && worker[:retiring] != true
170
+ end
171
+ end
172
+
173
+ def booted_workers(generation)
174
+ return 0 unless generation&.snapshot&.dig(:state) == "running"
175
+
176
+ generation.snapshot.fetch(:workers, []).count { |worker| worker[:state] == "ready" && worker[:retiring] != true }
177
+ end
178
+
179
+ def ready?
180
+ !@stopping && @active && ready_workers(@active) >= @active.metadata[:min_ready_workers]
181
+ end
182
+
183
+ def request_reexec
184
+ return if @stopping
185
+
186
+ if @candidate || @generations.values.any? { |generation| generation.role == :retiring }
187
+ @pending_reexec = true
188
+ return
189
+ end
190
+
191
+ if @active.metadata[:bind].end_with?(":0")
192
+ @reexec = { state: "failed", error: "USR2 requires a fixed RPC port" }
193
+ @logger.warn(@reexec[:error])
194
+ return
195
+ end
196
+ spawn_generation
197
+ rescue StandardError => e
198
+ @reexec = { state: "failed", error: e.message }
199
+ @logger.error("USR2 failed: #{e.message}")
200
+ end
201
+
202
+ def update_lifecycle
203
+ if @candidate&.role == :candidate
204
+ if @candidate.exit_status
205
+ fail_candidate("replacement master exited before readiness", force: false)
206
+ elsif @candidate.channel.closed?
207
+ fail_candidate("replacement master closed its control channel before readiness", force: false)
208
+ elsif monotonic >= @candidate.deadline
209
+ fail_candidate("replacement master readiness timed out")
210
+ elsif @candidate.metadata && (@active ? ready_workers(@candidate) : booted_workers(@candidate)) >= [@candidate.metadata[:workers], 1].max
211
+ promote_candidate
212
+ end
213
+ end
214
+ if @active&.exit_status && !@stopping
215
+ begin_shutdown(code: @active.exit_status.success? ? 0 : 1)
216
+ elsif @active&.snapshot&.dig(:state) == "draining" && !@stopping
217
+ begin_shutdown
218
+ end
219
+ if @active&.exit_status&.exited? && !@active.exit_status.success?
220
+ @exit_code = 1
221
+ end
222
+ @generations.each_value do |generation|
223
+ kill_group(generation) if generation.kill_at && monotonic >= generation.kill_at
224
+ next unless generation.group_reaped && generation.channel.closed?
225
+
226
+ generation.identities.each_value { |key| @metrics.forget(key) }
227
+ @generations.delete(generation.pid)
228
+ @candidate = nil if @candidate == generation
229
+ end
230
+ if @pending_reexec && !@stopping && !@candidate && @generations.values.none? { |generation| generation.role == :retiring }
231
+ @pending_reexec = false
232
+ request_reexec
233
+ end
234
+ end
235
+
236
+ def promote_candidate
237
+ replacement = @candidate
238
+ write_pid(replacement.pid)
239
+ if @active
240
+ @active.role = :retiring
241
+ terminate(@active)
242
+ @metrics.record_restart(reason: "hot_reexec")
243
+ @reexec = { state: "complete", pid: replacement.pid }
244
+ end
245
+ replacement.role = :active
246
+ @active = replacement
247
+ @candidate = nil
248
+ rescue StandardError => e
249
+ fail_candidate(e.message)
250
+ end
251
+
252
+ def fail_candidate(message, force: true)
253
+ return unless @candidate
254
+
255
+ @candidate.role = :failed
256
+ if force
257
+ kill_group(@candidate)
258
+ else
259
+ @candidate.kill_at = monotonic + 1
260
+ end
261
+ @reexec = { state: "failed", error: message }
262
+ @logger.error("USR2 failed: #{message}")
263
+ unless @active
264
+ @exit_code = 1
265
+ @stopping = true
266
+ end
267
+ end
268
+
269
+ def reap_generation(generation)
270
+ reap_exited_group(generation)
271
+ kill_group(generation) if generation.exit_status && !generation.group_reaped
272
+ end
273
+
274
+ def reap_exited_group(generation)
275
+ loop do
276
+ result = Process.waitpid2(-generation.pid, Process::WNOHANG)
277
+ break unless result
278
+
279
+ generation.exit_status = result.last if result.first == generation.pid
280
+ end
281
+ rescue Errno::ECHILD
282
+ generation.group_reaped = true
283
+ end
284
+
285
+ def handle_signal(name)
286
+ case name
287
+ when "TERM", "INT" then begin_shutdown
288
+ when "QUIT"
289
+ @stopping = true
290
+ @candidate.role = :stopping if @candidate
291
+ @generations.each_value { |generation| kill_group(generation) }
292
+ when "USR2" then request_reexec if @active
293
+ when "HUP"
294
+ @logger.reopen if @logger.respond_to?(:reopen)
295
+ @generations.each_value { |generation| signal_generation(generation, name) if %i[active retiring].include?(generation.role) }
296
+ when "USR1"
297
+ if @active && !@stopping
298
+ if @active.metadata[:workers].zero?
299
+ @logger.warn("USR1 requires worker processes; use USR2 to reload a single-process server")
300
+ else
301
+ signal_generation(@active, name)
302
+ end
303
+ end
304
+ when "TTIN", "TTOU" then signal_generation(@active, name) if @active && !@stopping
305
+ end
306
+ end
307
+
308
+ def begin_shutdown(code: 0)
309
+ @exit_code = code if code != 0
310
+ return if @stopping
311
+
312
+ @stopping = true
313
+ @generations.each_value do |generation|
314
+ generation.role = :stopping if generation.role == :candidate
315
+ terminate(generation)
316
+ end
317
+ end
318
+
319
+ def terminate(generation)
320
+ signal_generation(generation, "TERM")
321
+ metadata = generation.metadata || {}
322
+ generation.kill_at = monotonic + metadata.fetch(:drain_delay, 0) + metadata.fetch(:shutdown_timeout, 1) + 1
323
+ end
324
+
325
+ def signal_generation(generation, name)
326
+ Process.kill(name, generation.pid) unless generation.exit_status
327
+ rescue Errno::ESRCH
328
+ nil
329
+ end
330
+
331
+ def kill_group(generation)
332
+ reap_exited_group(generation)
333
+ return if generation.group_reaped || generation.kill_sent
334
+
335
+ Process.kill("KILL", -generation.pid)
336
+ generation.kill_sent = true
337
+ generation.kill_at = nil
338
+ rescue Errno::ESRCH
339
+ generation.kill_sent = true
340
+ end
341
+
342
+ def lock_pid_file(path)
343
+ @pid_path = File.expand_path(path)
344
+ FileUtils.mkdir_p(File.dirname(@pid_path))
345
+ @pid_lock = File.open("#{@pid_path}.lock", File::RDWR | File::CREAT, 0o644)
346
+ raise ConfigurationError, "PID file is already owned: #{@pid_path}" unless @pid_lock.flock(File::LOCK_EX | File::LOCK_NB)
347
+ end
348
+
349
+ def write_pid(pid)
350
+ return unless @pid_path
351
+
352
+ Tempfile.create([".gritz-pid", ".tmp"], File.dirname(@pid_path)) do |file|
353
+ file.write("#{pid}\n")
354
+ file.flush
355
+ File.rename(file.path, @pid_path)
356
+ end
357
+ @written_pid = pid
358
+ end
359
+
360
+ def cleanup
361
+ @generations.each_value do |generation|
362
+ kill_group(generation)
363
+ generation.channel.close
364
+ begin
365
+ loop { Process.waitpid(-generation.pid) }
366
+ rescue Errno::ECHILD
367
+ nil
368
+ end
369
+ end
370
+ @generations.clear
371
+ @admin&.close
372
+ @observer&.close
373
+ @signals&.close
374
+ if @written_pid && File.file?(@pid_path) && File.read(@pid_path).strip == @written_pid.to_s
375
+ File.unlink(@pid_path)
376
+ end
377
+ @pid_lock&.close
378
+ end
379
+ end
380
+ end
381
+ end