gritz-core 0.6.1 → 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 +11 -0
- data/README.md +4 -0
- data/lib/gritz/cli.rb +124 -3
- data/lib/gritz/compat/gruf.rb +1 -0
- data/lib/gritz/configuration.rb +2 -1
- data/lib/gritz/core/version.rb +1 -1
- data/lib/gritz/core.rb +2 -0
- data/lib/gritz/fork_guard.rb +1 -0
- data/lib/gritz/supervisor/launcher.rb +5 -3
- data/lib/gritz/testing/transport_contract.rb +52 -0
- metadata +2 -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,16 @@
|
|
|
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
|
+
|
|
9
|
+
## 0.9.0
|
|
10
|
+
|
|
11
|
+
- Start the 0.9 stabilization series with the documented public API and support policy.
|
|
12
|
+
- Reject directories and named pipes configured as TLS certificate, key or client CA files before allocating transport resources.
|
|
13
|
+
|
|
3
14
|
## 0.6.1
|
|
4
15
|
|
|
5
16
|
- Avoid formatting suppressed RPC completion logs, preserving responses, status mapping and metrics when INFO logging is disabled.
|
data/README.md
CHANGED
|
@@ -31,6 +31,10 @@ bundle exec rake build
|
|
|
31
31
|
|
|
32
32
|
Tests, lint and packaging run independently in this repository. See [CONTRIBUTING.md](CONTRIBUTING.md), [SECURITY.md](SECURITY.md) and the [release guide](docs/guides/releasing.md).
|
|
33
33
|
|
|
34
|
+
## Documentation
|
|
35
|
+
|
|
36
|
+
Read the [published guides and API reference](https://gritzrpc.github.io/gritz/), [public API policy](https://github.com/gritzrpc/gritz/blob/main/docs/public-api.md), [support policy](https://github.com/gritzrpc/gritz/blob/main/docs/support-policy.md) and [stabilization gate](https://github.com/gritzrpc/gritz/blob/main/docs/stabilization.md).
|
|
37
|
+
|
|
34
38
|
## License
|
|
35
39
|
|
|
36
40
|
[MIT](LICENSE.txt).
|
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/compat/gruf.rb
CHANGED
data/lib/gritz/configuration.rb
CHANGED
|
@@ -216,7 +216,8 @@ module Gritz
|
|
|
216
216
|
end
|
|
217
217
|
|
|
218
218
|
tls.each do |name, path|
|
|
219
|
-
|
|
219
|
+
valid = %i[cert key client_ca].include?(name) && path.is_a?(String) && File.file?(path) && File.readable?(path)
|
|
220
|
+
raise ConfigurationError, "Invalid tls #{name}" unless valid
|
|
220
221
|
end
|
|
221
222
|
end
|
|
222
223
|
end
|
data/lib/gritz/core/version.rb
CHANGED
data/lib/gritz/core.rb
CHANGED
|
@@ -4,6 +4,8 @@ require "zeitwerk"
|
|
|
4
4
|
require_relative "errors"
|
|
5
5
|
require_relative "core/version"
|
|
6
6
|
|
|
7
|
+
# Transport-independent RPC controllers, middleware, clients and lifecycle tools.
|
|
8
|
+
# @api public
|
|
7
9
|
module Gritz
|
|
8
10
|
module Core
|
|
9
11
|
# Core loading deliberately does not require grpc or initialize transports.
|
data/lib/gritz/fork_guard.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
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: gritz-core
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.9.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yudai Takada
|
|
@@ -171,6 +171,7 @@ licenses:
|
|
|
171
171
|
metadata:
|
|
172
172
|
allowed_push_host: https://rubygems.org
|
|
173
173
|
source_code_uri: https://github.com/gritzrpc/gritz-core
|
|
174
|
+
documentation_uri: https://gritzrpc.github.io/gritz/
|
|
174
175
|
changelog_uri: https://github.com/gritzrpc/gritz-core/blob/main/CHANGELOG.md
|
|
175
176
|
rubygems_mfa_required: 'true'
|
|
176
177
|
rdoc_options: []
|