gritz-core 0.9.0 → 0.9.1
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 +4 -4
- data/CHANGELOG.md +6 -0
- data/lib/gritz/cli.rb +124 -3
- data/lib/gritz/core/version.rb +1 -1
- data/lib/gritz/supervisor/launcher.rb +5 -3
- data/lib/gritz/testing/transport_contract.rb +52 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b938dbec401c131416b1a0a0b0f49fa9b140bf1567d8738f8f29d1c69dfa3ec1
|
|
4
|
+
data.tar.gz: df14f03e83c2ee4c72e62e7d7cedb9eab105541d41d9aae9ed2c71e929e7951c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 453162303ec6b73e2dba7aa2cbd878c86b3afd3d69ac2641d8f0813abf8a27e5c66171ffa50eabfbb35e5cfcc591d0c99e0783a897c214fa2bc38082366a27e5
|
|
7
|
+
data.tar.gz: b84efb28c932187519075a5ad79016cd1322650deaef419f1dccf091a557cb328b88a8b988ec2855fd0efd0c012574e0ccd775f97b935f9053b47ce0980d6253
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.1
|
|
4
|
+
|
|
5
|
+
- Add `gritz stats`, `stop` and `restart` to inspect the running server and signal its lifecycle owner without executing application configuration.
|
|
6
|
+
- Honor configured `reexec_timeout` values above 60 seconds for initial and replacement master readiness, including time already spent starting the generation.
|
|
7
|
+
- Check cooperative cancellation and pool-slot recovery for all four RPC forms in the shared transport contract.
|
|
8
|
+
|
|
3
9
|
## 0.9.0
|
|
4
10
|
|
|
5
11
|
- Start the 0.9 stabilization series with the documented public API and support policy.
|
data/lib/gritz/cli.rb
CHANGED
|
@@ -4,9 +4,11 @@ require "optparse"
|
|
|
4
4
|
require "logger"
|
|
5
5
|
require "rbconfig"
|
|
6
6
|
require "socket"
|
|
7
|
+
require "net/http"
|
|
8
|
+
require "json"
|
|
7
9
|
|
|
8
10
|
module Gritz
|
|
9
|
-
# Starts
|
|
11
|
+
# Starts and operates servers, lists routes, or checks configuration and fork safety.
|
|
10
12
|
# @api public
|
|
11
13
|
class CLI
|
|
12
14
|
CHILD_BOOTSTRAP = <<~RUBY
|
|
@@ -35,11 +37,13 @@ module Gritz
|
|
|
35
37
|
path = nil
|
|
36
38
|
overrides = {}
|
|
37
39
|
parser = OptionParser.new do |options|
|
|
38
|
-
options.banner = "Usage: gritz [start|routes|check] [-C config/gritz.rb] [options]"
|
|
40
|
+
options.banner = "Usage: gritz [start|routes|check|stats|stop|restart] [-C config/gritz.rb] [options]"
|
|
39
41
|
options.on("-C", "--config PATH", "Configuration file") { |value| path = value }
|
|
40
42
|
options.on("--workers N", Integer) { |value| overrides[:workers] = value }
|
|
41
43
|
options.on("--threads N", Integer) { |value| overrides[:threads] = value }
|
|
42
44
|
options.on("--bind ADDRESS") { |value| overrides[:bind] = value }
|
|
45
|
+
options.on("--admin-bind ADDRESS", "Admin HTTP host:port (operations do not load config)") { |value| overrides[:admin_bind] = value }
|
|
46
|
+
options.on("--pid-file PATH", "Active master PID file; operations fall back if Admin is unavailable") { |value| overrides[:pid_file] = value }
|
|
43
47
|
options.on("--strict-routes") { overrides[:strict_routes] = true }
|
|
44
48
|
options.on("-v", "--version") {
|
|
45
49
|
@stdout.puts(Core::VERSION)
|
|
@@ -52,9 +56,15 @@ module Gritz
|
|
|
52
56
|
end
|
|
53
57
|
parser.parse!(args)
|
|
54
58
|
command = args.shift || "start"
|
|
55
|
-
raise ConfigurationError, "Unknown command #{command}" unless %w[start routes check].include?(command)
|
|
59
|
+
raise ConfigurationError, "Unknown command #{command}" unless %w[start routes check stats stop restart].include?(command)
|
|
56
60
|
raise ConfigurationError, "Unexpected argument #{args.first}" unless args.empty?
|
|
57
61
|
|
|
62
|
+
if %w[stats stop restart].include?(command)
|
|
63
|
+
raise ConfigurationError, "#{command} does not load configuration; use --admin-bind or --pid-file instead of -C" if path
|
|
64
|
+
|
|
65
|
+
return run_operation(command, overrides)
|
|
66
|
+
end
|
|
67
|
+
|
|
58
68
|
logger = Logger.new(@stdout)
|
|
59
69
|
logger.formatter = ->(_severity, _time, _progname, message) { "#{message}\n" }
|
|
60
70
|
if command == "start" && @launch && !@owner_channel
|
|
@@ -127,5 +137,116 @@ module Gritz
|
|
|
127
137
|
@owner_channel&.close
|
|
128
138
|
listener&.close unless listener&.closed?
|
|
129
139
|
end
|
|
140
|
+
|
|
141
|
+
private
|
|
142
|
+
|
|
143
|
+
def run_operation(command, overrides)
|
|
144
|
+
address = overrides.fetch(:admin_bind) { @env.fetch("GRITZ_ADMIN_BIND", Configuration::DEFAULTS[:admin_bind]) }
|
|
145
|
+
path = overrides.fetch(:pid_file) { @env.fetch("GRITZ_PID_FILE", "") }
|
|
146
|
+
file_pid = read_pid_file(path) unless path.empty? || command == "stats"
|
|
147
|
+
status = read_admin_status(address, fallback: !file_pid.nil?)
|
|
148
|
+
if command == "stats"
|
|
149
|
+
@stdout.puts "State: #{status.fetch('state')} Master PID: #{status.fetch('pid')} Owner PID: #{status['owner_pid'] || 'n/a'}"
|
|
150
|
+
@stdout.puts "WORKER PID STATE RSS PSS"
|
|
151
|
+
status.fetch("workers").each do |worker|
|
|
152
|
+
@stdout.puts "#{worker['index']} #{worker.fetch('pid')} #{worker.fetch('state')} " \
|
|
153
|
+
"#{format_bytes(worker['rss_bytes'])} #{format_bytes(worker['pss_bytes'])}"
|
|
154
|
+
end
|
|
155
|
+
else
|
|
156
|
+
if status && file_pid && status.fetch("pid") != file_pid
|
|
157
|
+
raise ConfigurationError, "Admin master PID #{status.fetch('pid')} does not match PID file #{file_pid}"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
pid = status ? status.fetch("owner_pid", status.fetch("pid")) : file_pid
|
|
161
|
+
signal = command == "stop" ? "TERM" : "USR2"
|
|
162
|
+
begin
|
|
163
|
+
Process.kill(signal, pid)
|
|
164
|
+
rescue SystemCallError => e
|
|
165
|
+
raise ConfigurationError, "Cannot send #{signal} to PID #{pid}: #{e.message}"
|
|
166
|
+
end
|
|
167
|
+
@stdout.puts "Sent #{signal} to PID #{pid}"
|
|
168
|
+
end
|
|
169
|
+
0
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def read_pid_file(path)
|
|
173
|
+
File.open(path, File::RDONLY | File::NONBLOCK) do |file|
|
|
174
|
+
raise ConfigurationError, "PID file must be a regular file: #{path}" unless file.stat.file?
|
|
175
|
+
raise ConfigurationError, "Invalid PID file: #{path}" if file.stat.size > 64
|
|
176
|
+
|
|
177
|
+
value = file.read(65).to_s.strip
|
|
178
|
+
raise ConfigurationError, "Invalid PID file: #{path}" unless value.match?(/\A[0-9]{1,10}\z/)
|
|
179
|
+
|
|
180
|
+
validate_pid!(Integer(value, 10))
|
|
181
|
+
end
|
|
182
|
+
rescue SystemCallError => e
|
|
183
|
+
raise ConfigurationError, "Cannot read PID file #{path}: #{e.message}"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def read_admin_status(address, fallback:)
|
|
187
|
+
match = address.match(/\A(\[[a-zA-Z0-9_.:%-]+\]|[a-zA-Z0-9_.-]+):([0-9]+)\z/)
|
|
188
|
+
raise ConfigurationError, "Invalid Admin address: #{address}" unless match && Integer(match[2], 10).between?(1, 65_535)
|
|
189
|
+
|
|
190
|
+
# Operations always target the requested endpoint, never an environment HTTP proxy.
|
|
191
|
+
http = Net::HTTP.new(match[1].delete_prefix("[").delete_suffix("]"), Integer(match[2], 10), nil)
|
|
192
|
+
http.open_timeout = http.read_timeout = 2
|
|
193
|
+
http.max_retries = 0
|
|
194
|
+
body = +""
|
|
195
|
+
response_started = false
|
|
196
|
+
http.start do |client|
|
|
197
|
+
client.request(Net::HTTP::Get.new("/status")) do |response|
|
|
198
|
+
response_started = true
|
|
199
|
+
raise ConfigurationError, "Admin /status returned HTTP #{response.code}" unless response.code == "200"
|
|
200
|
+
|
|
201
|
+
response.ignore_eof = false
|
|
202
|
+
response.read_body do |chunk|
|
|
203
|
+
raise ConfigurationError, "Admin /status response exceeds 1 MiB" if body.bytesize + chunk.bytesize > 1_048_576
|
|
204
|
+
|
|
205
|
+
body << chunk
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
validate_status!(JSON.parse(body))
|
|
210
|
+
rescue SystemCallError, IOError, SocketError, Timeout::Error => e
|
|
211
|
+
return nil if fallback && !response_started
|
|
212
|
+
|
|
213
|
+
raise ConfigurationError, "Admin /status unavailable at #{address}: #{e.message}; stop/restart can use --pid-file"
|
|
214
|
+
rescue JSON::ParserError, Net::ProtocolError, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError => e
|
|
215
|
+
raise ConfigurationError, "Invalid Admin /status response: #{e.message}"
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def validate_pid!(pid)
|
|
219
|
+
unless pid.is_a?(Integer) && pid.between?(2, 2_147_483_647) && pid != Process.pid
|
|
220
|
+
raise ConfigurationError, "Invalid server PID: #{pid.inspect}"
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
pid
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def validate_status!(status)
|
|
227
|
+
unless status.is_a?(Hash) && %w[starting running draining].include?(status["state"]) && status["workers"].is_a?(Array)
|
|
228
|
+
raise ConfigurationError, "Invalid Admin /status process state"
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
validate_pid!(status["pid"])
|
|
232
|
+
validate_pid!(status["owner_pid"]) if status.key?("owner_pid")
|
|
233
|
+
status["workers"].each do |worker|
|
|
234
|
+
unless worker.is_a?(Hash) && Supervisor::WorkerHandle::STATES.include?(worker["state"]) &&
|
|
235
|
+
worker["index"].is_a?(Integer) && worker["index"] >= 0
|
|
236
|
+
raise ConfigurationError, "Invalid Admin /status worker state"
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
validate_pid!(worker["pid"])
|
|
240
|
+
%w[rss_bytes pss_bytes].each do |name|
|
|
241
|
+
value = worker[name]
|
|
242
|
+
unless value.nil? || (value.is_a?(Numeric) && value.real? && value.finite? && value >= 0)
|
|
243
|
+
raise ConfigurationError, "Invalid Admin /status #{name}"
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
status
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def format_bytes(value) = value ? format("%.2f MiB", value / (1024.0 * 1024)) : "n/a"
|
|
130
251
|
end
|
|
131
252
|
end
|
data/lib/gritz/core/version.rb
CHANGED
|
@@ -9,7 +9,7 @@ module Gritz
|
|
|
9
9
|
# Keeps process ownership and probes stable while fresh master interpreters replace each other.
|
|
10
10
|
# @api private
|
|
11
11
|
class Launcher
|
|
12
|
-
Generation = Struct.new(:pid, :channel, :token, :role, :metadata, :snapshot, :deadline,
|
|
12
|
+
Generation = Struct.new(:pid, :channel, :token, :role, :metadata, :snapshot, :started_at, :deadline,
|
|
13
13
|
:kill_at, :kill_sent, :exit_status, :group_reaped, :identities, keyword_init: true)
|
|
14
14
|
SIGNALS = %w[TERM INT QUIT USR1 USR2 TTIN TTOU HUP CHLD].freeze
|
|
15
15
|
|
|
@@ -91,11 +91,12 @@ module Gritz
|
|
|
91
91
|
|
|
92
92
|
def spawn_generation
|
|
93
93
|
parent, child = UNIXSocket.pair
|
|
94
|
+
started_at = monotonic
|
|
94
95
|
pid = Process.spawn(@env.to_h.merge("GRITZ_INTERNAL_OWNER_FD" => "3"), *@command,
|
|
95
96
|
3 => child, out: @stdout, err: @stderr, pgroup: true)
|
|
96
97
|
@serial += 1
|
|
97
98
|
@candidate = Generation.new(pid: pid, channel: StatusChannel.new(parent), token: @serial, role: :candidate,
|
|
98
|
-
deadline:
|
|
99
|
+
started_at:, deadline: started_at + @startup_timeout, identities: {})
|
|
99
100
|
@generations[pid] = @candidate
|
|
100
101
|
@reexec = { state: "starting", pid: pid } if @active
|
|
101
102
|
rescue StandardError
|
|
@@ -137,6 +138,7 @@ module Gritz
|
|
|
137
138
|
|
|
138
139
|
def configure_generation(generation, metadata)
|
|
139
140
|
return if generation.metadata || @stopping
|
|
141
|
+
raise ConfigurationError, "replacement master configuration timed out" if monotonic >= generation.deadline
|
|
140
142
|
|
|
141
143
|
if metadata[:listener_strategy] && !%w[reuseport inherited_fd].include?(metadata[:listener_strategy])
|
|
142
144
|
raise ConfigurationError, "Invalid master listener_strategy"
|
|
@@ -163,7 +165,7 @@ module Gritz
|
|
|
163
165
|
lock_pid_file(metadata[:pid_file]) unless metadata[:pid_file].empty?
|
|
164
166
|
end
|
|
165
167
|
generation.metadata = metadata
|
|
166
|
-
generation.deadline =
|
|
168
|
+
generation.deadline = generation.started_at + metadata[:reexec_timeout]
|
|
167
169
|
if metadata[:listener_strategy] == "inherited_fd"
|
|
168
170
|
@listener ||= Listener.bind(metadata[:bind])
|
|
169
171
|
generation.channel.io.send_io(@listener)
|
|
@@ -218,6 +218,58 @@ RSpec.shared_examples Gritz::Testing::TransportContract do |adapter:, service:,
|
|
|
218
218
|
end
|
|
219
219
|
end
|
|
220
220
|
|
|
221
|
+
%i[say_hello record_names list_greetings chat].each do |action|
|
|
222
|
+
it "lets a cooperative #{action} controller observe cancellation and releases its serving slot" do
|
|
223
|
+
entered = Queue.new
|
|
224
|
+
completed = Queue.new
|
|
225
|
+
release = Queue.new
|
|
226
|
+
response = contract_reply
|
|
227
|
+
controller = contract_controller do
|
|
228
|
+
define_method(action) do
|
|
229
|
+
observing = self.request.message.name == "cancel"
|
|
230
|
+
if observing
|
|
231
|
+
entered << true
|
|
232
|
+
context.check_cancelled! until release.pop(timeout: 0.005)
|
|
233
|
+
end
|
|
234
|
+
result = response.new(message: "available")
|
|
235
|
+
context.method.server_streaming? ? stream.write(result) : result
|
|
236
|
+
ensure
|
|
237
|
+
completed << true if observing
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
with_contract_server(controller, threads: 1) do |client, server|
|
|
241
|
+
streaming_input = %i[record_names chat].include?(action)
|
|
242
|
+
message = contract_request.new(name: "cancel")
|
|
243
|
+
operation = client.public_send(action, streaming_input ? [message] : message, return_op: true, deadline: Time.now + 5)
|
|
244
|
+
caller = Thread.new do
|
|
245
|
+
result = operation.execute
|
|
246
|
+
result.to_a if %i[list_greetings chat].include?(action)
|
|
247
|
+
rescue GRPC::BadStatus => e
|
|
248
|
+
e
|
|
249
|
+
end
|
|
250
|
+
expect(entered.pop(timeout: 2)).to be(true)
|
|
251
|
+
operation.cancel
|
|
252
|
+
expect(caller.join(2)).not_to be_nil
|
|
253
|
+
expect(caller.value).to be_a(GRPC::Cancelled)
|
|
254
|
+
expect(completed.pop(timeout: 1)).to be(true)
|
|
255
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 1
|
|
256
|
+
until server.transport.stats[:inflight].zero? && (adapter == :async || server.transport.stats[:busy].zero?)
|
|
257
|
+
raise "cancelled controller retained its serving slot" if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
258
|
+
|
|
259
|
+
sleep 0.005
|
|
260
|
+
end
|
|
261
|
+
message = contract_request.new
|
|
262
|
+
result = client.public_send(action, streaming_input ? [message] : message, deadline: Time.now + 1)
|
|
263
|
+
result = result.to_a.first if %i[list_greetings chat].include?(action)
|
|
264
|
+
expect(result.message).to eq("available")
|
|
265
|
+
ensure
|
|
266
|
+
release << true
|
|
267
|
+
operation&.cancel
|
|
268
|
+
caller&.join(2)
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
221
273
|
it "finishes an in-flight response during graceful shutdown" do
|
|
222
274
|
entered = Queue.new
|
|
223
275
|
gate = Queue.new
|