gritz-core 0.4.0 → 0.6.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +17 -0
- data/README.md +5 -1
- data/exe/gritz-migrate-gruf +25 -0
- data/lib/gritz/cli.rb +24 -5
- data/lib/gritz/compat/gruf/config_converter.rb +159 -0
- data/lib/gritz/compat/gruf.rb +201 -0
- data/lib/gritz/configuration.rb +10 -4
- data/lib/gritz/controller.rb +8 -1
- data/lib/gritz/core/version.rb +1 -1
- data/lib/gritz/core.rb +2 -1
- data/lib/gritz/middleware/exception_mapper.rb +2 -0
- data/lib/gritz/supervisor/admin_server.rb +2 -1
- data/lib/gritz/supervisor/launcher.rb +12 -3
- data/lib/gritz/supervisor/listener.rb +25 -0
- data/lib/gritz/supervisor/master.rb +22 -8
- data/lib/gritz/supervisor/signal_queue.rb +2 -1
- data/lib/gritz/supervisor/status_channel.rb +4 -2
- data/lib/gritz/testing/server.rb +93 -0
- data/lib/gritz/testing/transport_contract.rb +271 -0
- data/lib/gritz/worker/runner.rb +6 -5
- metadata +10 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fc43859887cac5ffa9e137f2cab8911a4a6ef79ed4c522380d55869446c360a9
|
|
4
|
+
data.tar.gz: adfbe678ac9b1af0f507cad8fb69c16bfea7acbe4a422bacfce1e6b90747f34b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0c1d9ef947490cfa7c5832cca9d1c20f0c7e093798e56c4d3b0b261c80279ae1a55f2b747ea2f9c42af99c126ac91a3c55098d18a186d61a5ab362e1caad0b1d
|
|
7
|
+
data.tar.gz: 6e859a121f10896fb4e38f8cbf669e33ce504637026aa2269d338f003a64f02190b8299b59e145a4909bd5cf6cbd0d62fbab3fac630952154e880cd900cf0014
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.0
|
|
4
|
+
|
|
5
|
+
- Run Async workers through the same supervisor, CLI and real-server test helper as Native workers.
|
|
6
|
+
- Retain one inherited listener across forked workers and fresh master replacement, including ephemeral ports and recycling.
|
|
7
|
+
- Share adapter wire tests through `Gritz::Testing::TransportContract` without loading optional test dependencies in production.
|
|
8
|
+
- Preserve Async deadline errors through the standard exception middleware.
|
|
9
|
+
|
|
10
|
+
## 0.5.0
|
|
11
|
+
|
|
12
|
+
- Migrate Gruf controllers and server interceptors through `Gritz::Compat::Gruf`, with a configuration conversion tool.
|
|
13
|
+
- Configure optional gRPC Reflection through the `reflection` setting.
|
|
14
|
+
- Avoid unnecessary supervisor snapshot allocation when status consumers are paused.
|
|
15
|
+
- Publish worker metrics at `status_interval` to reduce supervisor overhead; retain retries and final shutdown totals.
|
|
16
|
+
- Eagerly load the transport-independent framework before application warmup so forked workers can share its memory.
|
|
17
|
+
- Send supervisor snapshots at `status_interval`, with immediate lifecycle and health changes, to reduce parent-process allocation.
|
|
18
|
+
- Reuse signal, status and admin read buffers to avoid allocating memory on every idle poll.
|
|
19
|
+
|
|
3
20
|
## 0.4.0
|
|
4
21
|
|
|
5
22
|
- Define lazy, fork-safe clients with process-local shared connections, per-call middleware, deadline propagation and selected request headers.
|
data/README.md
CHANGED
|
@@ -6,14 +6,18 @@ Transport-independent controllers, routing, middleware, configuration, forked-wo
|
|
|
6
6
|
require "gritz/core"
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
-
Use [gritz](https://github.com/gritzrpc/gritz) for the default framework and executable, or combine this gem with [gritz-native](https://github.com/gritzrpc/gritz-native) for the official grpc gem adapter. The
|
|
9
|
+
Use [gritz](https://github.com/gritzrpc/gritz) for the default framework and executable, or combine this gem with [gritz-native](https://github.com/gritzrpc/gritz-native) for the official grpc gem adapter. The experimental [gritz-async](https://github.com/gritzrpc/gritz-async) adapter runs RPCs in fibers and supports inherited TCP listeners without installing the official grpc gem.
|
|
10
10
|
|
|
11
11
|
Controller tests can load `gritz/testing/rspec` or `gritz/testing/minitest` and run without a network or the grpc gem. See the [controller examples](https://github.com/gritzrpc/gritz#controllers) and [configuration guide](docs/guides/configuration.md).
|
|
12
12
|
|
|
13
|
+
`Gritz::Testing::Server` selects the configured adapter for real-socket tests; require that adapter's entrypoint first. Adapter suites explicitly require `gritz/testing/transport_contract` to run shared wire checks with RSpec, the official grpc client and googleapis-common-protos-types as test dependencies. Production eager loading skips this shared test suite.
|
|
14
|
+
|
|
13
15
|
Supervised servers support phased replacement, fresh-interpreter hot reexec, request/memory/lifetime recycling, Admin HTTP probes and process-wide Prometheus totals. The CLI keeps one lifecycle owner across master generations; application controllers and gRPC resources stay in serving processes.
|
|
14
16
|
|
|
15
17
|
`Gritz::Client.define` creates lazy, fork-safe client constants with deadline propagation, selected request headers and middleware around complete streams. See the [client guide](docs/guides/clients.md). Optional [gritz-otel](https://github.com/gritzrpc/gritz-otel) adds server/client spans and worker OTLP metrics after fork.
|
|
16
18
|
|
|
19
|
+
The optional `Gritz::Compat::Gruf` module adapts Gruf controllers, returned response streams and server interceptors. `gritz-migrate-gruf` converts supported static configuration without executing the source file. See the [Gruf migration guide](docs/guides/migrating-from-gruf.md). Add [gritz-rails](https://github.com/gritzrpc/gritz-rails) for Rails execution, generators and development reloading.
|
|
20
|
+
|
|
17
21
|
## Development
|
|
18
22
|
|
|
19
23
|
```sh
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "gritz/core"
|
|
5
|
+
require "optparse"
|
|
6
|
+
|
|
7
|
+
begin
|
|
8
|
+
destination = nil
|
|
9
|
+
parser = OptionParser.new do |options|
|
|
10
|
+
options.banner = "Usage: gritz-migrate-gruf SOURCE [--output config/gritz.rb]"
|
|
11
|
+
options.on("--output PATH", "Write a new file; existing files are never overwritten") { |path| destination = path }
|
|
12
|
+
end
|
|
13
|
+
parser.parse!
|
|
14
|
+
raise OptionParser::MissingArgument, "SOURCE" unless ARGV.size == 1
|
|
15
|
+
|
|
16
|
+
output = Gritz::Compat::Gruf::ConfigConverter.new.convert(File.read(ARGV.first))
|
|
17
|
+
if destination
|
|
18
|
+
File.open(destination, File::WRONLY | File::CREAT | File::EXCL) { |file| file.write(output) }
|
|
19
|
+
else
|
|
20
|
+
$stdout.write(output)
|
|
21
|
+
end
|
|
22
|
+
rescue Gritz::ConfigurationError, OptionParser::ParseError, SystemCallError => e
|
|
23
|
+
warn "gritz-migrate-gruf: #{e.message}"
|
|
24
|
+
exit 1
|
|
25
|
+
end
|
data/lib/gritz/cli.rb
CHANGED
|
@@ -16,7 +16,8 @@ module Gritz
|
|
|
16
16
|
RUBY
|
|
17
17
|
|
|
18
18
|
def self.child_command(argv)
|
|
19
|
-
|
|
19
|
+
library = defined?(Transport::Async) ? "gritz/async" : "gritz/core"
|
|
20
|
+
[RbConfig.ruby, "-I", $LOAD_PATH.join(File::PATH_SEPARATOR), "-r#{library}", "-e", CHILD_BOOTSTRAP, "--", *argv]
|
|
20
21
|
end
|
|
21
22
|
|
|
22
23
|
def initialize(stdout: $stdout, stderr: $stderr, env: ENV, status_io: nil, owner_io: nil, launch: false, launch_command: nil)
|
|
@@ -63,7 +64,9 @@ module Gritz
|
|
|
63
64
|
|
|
64
65
|
path ||= "config/gritz.rb" if File.file?("config/gritz.rb")
|
|
65
66
|
guard = ForkGuard.activate(mode: :record) if command != "routes"
|
|
66
|
-
|
|
67
|
+
if command != "routes" && Gem.loaded_specs.key?("gritz-native") && !defined?(Transport::Async)
|
|
68
|
+
require "gritz/native"
|
|
69
|
+
end
|
|
67
70
|
config = Configuration.load(path: path, env: @env, overrides: overrides)
|
|
68
71
|
if command == "check"
|
|
69
72
|
config.preload! if config.preload_app?
|
|
@@ -71,10 +74,12 @@ module Gritz
|
|
|
71
74
|
return 1 unless guard.violations.empty?
|
|
72
75
|
|
|
73
76
|
config.validate_runtime!
|
|
77
|
+
require "gritz/#{config.transport}"
|
|
74
78
|
Router.new(controllers: config.controllers, strict: config.strict_routes, logger: logger)
|
|
75
79
|
@stdout.puts "Configuration and fork safety checks passed"
|
|
76
80
|
elsif command == "start"
|
|
77
81
|
config.validate_runtime!
|
|
82
|
+
require "gritz/#{config.transport}"
|
|
78
83
|
if config.workers.positive? && config.fork_mode == :clean
|
|
79
84
|
raise guard.violations.first if config.fork_guard == :raise && !guard.violations.empty?
|
|
80
85
|
|
|
@@ -83,14 +88,27 @@ module Gritz
|
|
|
83
88
|
@stdout.sync = true if @stdout.respond_to?(:sync=)
|
|
84
89
|
@owner_channel&.write(type: "configured", pid: Process.pid, workers: config.workers, bind: config.bind,
|
|
85
90
|
admin_bind: config.admin_bind, min_ready_workers: config.min_ready_workers, pid_file: config.pid_file,
|
|
86
|
-
reexec_timeout: config.reexec_timeout, drain_delay: config.drain_delay, shutdown_timeout: config.shutdown_timeout
|
|
91
|
+
reexec_timeout: config.reexec_timeout, drain_delay: config.drain_delay, shutdown_timeout: config.shutdown_timeout,
|
|
92
|
+
listener_strategy: config.listener_strategy.to_s)
|
|
93
|
+
if @owner_channel && config.listener_strategy == :inherited_fd
|
|
94
|
+
until @owner_channel.flush
|
|
95
|
+
raise ConfigurationError, "listener owner closed its control channel" if @owner_channel.closed?
|
|
96
|
+
|
|
97
|
+
@owner_channel.io.wait_writable(0.01)
|
|
98
|
+
end
|
|
99
|
+
begin
|
|
100
|
+
listener = @owner_channel.io.recv_io(Socket)
|
|
101
|
+
rescue IOError, SocketError => e
|
|
102
|
+
raise ConfigurationError, "listener owner failed to pass its socket: #{e.message}"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
87
105
|
if config.workers.positive?
|
|
88
|
-
return Supervisor::Master.new(config, logger: logger, status_io: @status_io, owner_channel: @owner_channel).run
|
|
106
|
+
return Supervisor::Master.new(config, logger: logger, status_io: @status_io, owner_channel: @owner_channel, listener:).run
|
|
89
107
|
end
|
|
90
108
|
|
|
91
109
|
ForkGuard.deactivate
|
|
92
110
|
config.preload! if config.preload_app?
|
|
93
|
-
return Worker::Runner.new(index: 0, config: config, logger: logger, owner_channel: @owner_channel).run
|
|
111
|
+
return Worker::Runner.new(index: 0, config: config, logger: logger, owner_channel: @owner_channel, listener:).run
|
|
94
112
|
else
|
|
95
113
|
config.preload! if config.preload_app?
|
|
96
114
|
router = Router.new(controllers: config.controllers, strict: config.strict_routes, logger: logger)
|
|
@@ -107,6 +125,7 @@ module Gritz
|
|
|
107
125
|
ensure
|
|
108
126
|
ForkGuard.deactivate if guard
|
|
109
127
|
@owner_channel&.close
|
|
128
|
+
listener&.close unless listener&.closed?
|
|
110
129
|
end
|
|
111
130
|
end
|
|
112
131
|
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ripper"
|
|
4
|
+
|
|
5
|
+
module Gritz
|
|
6
|
+
module Compat
|
|
7
|
+
module Gruf
|
|
8
|
+
# Converts a deliberately small, literal-only subset of Gruf.configure.
|
|
9
|
+
# @api public
|
|
10
|
+
class ConfigConverter
|
|
11
|
+
ARGUMENTS = {
|
|
12
|
+
"grpc.max_receive_message_length" => [:max_receive_message_size, 1],
|
|
13
|
+
"grpc.max_send_message_length" => [:max_send_message_size, 1],
|
|
14
|
+
"grpc.max_metadata_size" => [:max_metadata_size, 1],
|
|
15
|
+
"grpc.max_connection_age_ms" => [:max_connection_age, 1000.0],
|
|
16
|
+
"grpc.max_connection_age_grace_ms" => [:max_connection_age_grace, 1000.0],
|
|
17
|
+
"grpc.keepalive_time_ms" => [:keepalive_time, 1000.0]
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
# @param source [String] Ruby source containing one static Gruf.configure block
|
|
21
|
+
# @return [String] Gritz configuration requiring manual review before use
|
|
22
|
+
# @raise [Gritz::ConfigurationError] when a setting or expression cannot be converted
|
|
23
|
+
# @api public
|
|
24
|
+
def convert(source)
|
|
25
|
+
settings = assignments(source)
|
|
26
|
+
output = { workers: 0 }
|
|
27
|
+
settings.each do |name, value|
|
|
28
|
+
case name
|
|
29
|
+
when "server_binding_url" then output[:bind] = value
|
|
30
|
+
when "rpc_server_options" then server_options(value, output)
|
|
31
|
+
when "use_ssl", "ssl_crt_file", "ssl_key_file" then next
|
|
32
|
+
when "default_client_host", "default_channel_credentials"
|
|
33
|
+
unsupported("#{name}: configure Gritz::Client.define manually")
|
|
34
|
+
else unsupported("#{name}: manual migration required; see migrating-from-gruf.md")
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
config = Gritz::Configuration.new
|
|
38
|
+
output.each { |name, value| config.public_send("#{name}=", value) }
|
|
39
|
+
config.validate!
|
|
40
|
+
tls = tls_options(settings)
|
|
41
|
+
output[:tls] = tls unless tls.empty?
|
|
42
|
+
["# Review controller registration, interceptors and fork safety before starting.",
|
|
43
|
+
'require "gritz/compat/gruf"', "", *output.map { |name, value| "#{name}(#{value.inspect})" }, "",
|
|
44
|
+
"# register_controller YourMigratedController", ""].join("\n")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def assignments(source)
|
|
50
|
+
tree = Ripper.sexp(source)
|
|
51
|
+
unsupported("invalid Ruby syntax; manual correction required") unless tree
|
|
52
|
+
statements = tree[1].reject { |node| node[0] == :void_stmt }
|
|
53
|
+
unsupported("expected one Gruf.configure block; other executable code needs manual migration") unless statements.size == 1
|
|
54
|
+
node = statements.first
|
|
55
|
+
call = node[1]
|
|
56
|
+
block = node[2]
|
|
57
|
+
unless node[0] == :method_add_block && call&.dig(0) == :call && call.dig(3, 1) == "configure" &&
|
|
58
|
+
%i[var_ref top_const_ref].include?(call.dig(1, 0)) && call.dig(1, 1, 1) == "Gruf"
|
|
59
|
+
unsupported("expected Gruf.configure; manual migration required")
|
|
60
|
+
end
|
|
61
|
+
params = block&.dig(1, 1)
|
|
62
|
+
unless %i[do_block brace_block].include?(block&.dig(0)) && params&.dig(0) == :params &&
|
|
63
|
+
params[1]&.size == 1 && params[2..].all?(&:nil?) && block.dig(1, 2) == false
|
|
64
|
+
unsupported("configure needs one block variable; manual migration required")
|
|
65
|
+
end
|
|
66
|
+
variable = params[1][0][1]
|
|
67
|
+
body = block[2]
|
|
68
|
+
if block[0] == :do_block
|
|
69
|
+
unsupported("rescue/ensure needs manual migration") unless body[2..].all?(&:nil?)
|
|
70
|
+
body = body[1]
|
|
71
|
+
end
|
|
72
|
+
body.each_with_object({}) do |statement, values|
|
|
73
|
+
next if statement[0] == :void_stmt
|
|
74
|
+
|
|
75
|
+
field = statement[1]
|
|
76
|
+
unless statement[0] == :assign && field&.dig(0) == :field && field.dig(1, 0) == :var_ref && field.dig(1, 1, 1) == variable
|
|
77
|
+
unsupported("only literal assignments are supported; interceptors and other code require manual migration")
|
|
78
|
+
end
|
|
79
|
+
name = field[3][1]
|
|
80
|
+
unsupported("duplicate setting #{name}; manual migration required") if values.key?(name)
|
|
81
|
+
values[name] = literal(statement[2])
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def literal(node)
|
|
86
|
+
case node[0]
|
|
87
|
+
when :@int then Integer(node[1].delete("_"), 0)
|
|
88
|
+
when :@float then Float(node[1].delete("_"))
|
|
89
|
+
when :string_literal
|
|
90
|
+
parts = node[1][1..]
|
|
91
|
+
unsupported("strings must be literals without interpolation or escapes") unless parts.all? { |part|
|
|
92
|
+
part[0] == :@tstring_content && !part[1].include?("\\")
|
|
93
|
+
}
|
|
94
|
+
parts.map { |part| part[1] }.join
|
|
95
|
+
when :symbol_literal
|
|
96
|
+
token = node.dig(1, 1)
|
|
97
|
+
unsupported("symbol must be a static literal") unless %i[@ident @op @const].include?(token&.dig(0))
|
|
98
|
+
token[1].to_sym
|
|
99
|
+
when :@label then node[1].delete_suffix(":").to_sym
|
|
100
|
+
when :var_ref
|
|
101
|
+
return { "true" => true, "false" => false, "nil" => nil }.fetch(node[1][1]) if node[1][0] == :@kw
|
|
102
|
+
|
|
103
|
+
unsupported("dynamic values need manual migration; use static literals")
|
|
104
|
+
when :unary
|
|
105
|
+
unsupported("only negative numeric literals are supported") unless node[1] == :-@ && %i[@int @float].include?(node.dig(2, 0))
|
|
106
|
+
-literal(node[2])
|
|
107
|
+
when :hash
|
|
108
|
+
pairs = node.dig(1, 1) || []
|
|
109
|
+
pairs.each_with_object({}) do |pair, values|
|
|
110
|
+
unsupported("hash splats need manual migration; use literal entries") unless pair[0] == :assoc_new
|
|
111
|
+
key = literal(pair[1])
|
|
112
|
+
unsupported("duplicate hash key #{key}; manual migration required") if values.key?(key)
|
|
113
|
+
values[key] = literal(pair[2])
|
|
114
|
+
end
|
|
115
|
+
else unsupported("dynamic values need manual migration; only static literals are supported")
|
|
116
|
+
end
|
|
117
|
+
rescue ArgumentError, KeyError
|
|
118
|
+
unsupported("invalid literal; manual migration required")
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def server_options(options, output)
|
|
122
|
+
unsupported("rpc_server_options must be a literal Hash") unless options.is_a?(Hash)
|
|
123
|
+
options.each do |name, value|
|
|
124
|
+
case name
|
|
125
|
+
when :pool_size then output[:threads] = value
|
|
126
|
+
when :max_waiting_requests then output[:max_waiting_requests] = value
|
|
127
|
+
when :server_args
|
|
128
|
+
unsupported("server_args must be a literal Hash") unless value.is_a?(Hash)
|
|
129
|
+
value.each do |key, argument|
|
|
130
|
+
conversion = ARGUMENTS[key]
|
|
131
|
+
unsupported("#{key}: manual C-core argument migration required") unless conversion
|
|
132
|
+
unsupported("#{key} must be a numeric literal") unless argument.is_a?(Numeric)
|
|
133
|
+
setting, divisor = conversion
|
|
134
|
+
output[setting] = argument / divisor
|
|
135
|
+
end
|
|
136
|
+
else unsupported("#{name}: manual rpc_server_options migration required")
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def tls_options(settings)
|
|
142
|
+
enabled = settings.fetch("use_ssl", false)
|
|
143
|
+
unsupported("use_ssl must be a boolean literal") unless [true, false].include?(enabled)
|
|
144
|
+
paths = %w[ssl_crt_file ssl_key_file]
|
|
145
|
+
unless enabled
|
|
146
|
+
paths.each { |key| unsupported("#{key} requires use_ssl=true; manual review required") if settings.key?(key) }
|
|
147
|
+
return {}
|
|
148
|
+
end
|
|
149
|
+
unless paths.all? { |key| settings[key].is_a?(String) && !settings[key].empty? }
|
|
150
|
+
unsupported("use_ssl=true requires ssl_crt_file and ssl_key_file; manual review required")
|
|
151
|
+
end
|
|
152
|
+
{ cert: settings.fetch("ssl_crt_file"), key: settings.fetch("ssl_key_file") }
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def unsupported(message) = raise Gritz::ConfigurationError, message
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "gritz/core"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Gritz
|
|
7
|
+
module Compat
|
|
8
|
+
# Selected Gruf 2.22 controller/interceptor APIs, without loading Gruf or grpc.
|
|
9
|
+
# @api public
|
|
10
|
+
module Gruf
|
|
11
|
+
REQUEST_KEY = Object.new.freeze
|
|
12
|
+
|
|
13
|
+
# @api private
|
|
14
|
+
def self.request_for(context)
|
|
15
|
+
context.store[REQUEST_KEY] ||= Request.new(context)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Wraps a Gruf-style interceptor as a Gritz server middleware.
|
|
19
|
+
# @param klass [Class] interceptor implementing #call and yielding to the action
|
|
20
|
+
# @return [Class] middleware accepted by Middleware::Stack#use
|
|
21
|
+
# @api public
|
|
22
|
+
def self.interceptor(klass)
|
|
23
|
+
Class.new(InterceptorAdapter) do
|
|
24
|
+
define_method(:initialize) { |app, **options| super(app, klass, options) }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
class RequestContext < Hash
|
|
29
|
+
def [](key) = super(convert_key(key))
|
|
30
|
+
|
|
31
|
+
def []=(key, value)
|
|
32
|
+
super(convert_key(key), value)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def fetch(key, ...) = super(convert_key(key), ...)
|
|
36
|
+
def key?(key) = super(convert_key(key))
|
|
37
|
+
def merge!(other, &) = super(other.transform_keys { |key| convert_key(key) }, &)
|
|
38
|
+
alias update merge!
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def convert_key(key) = key.is_a?(Symbol) ? key.to_s : key
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
class ActiveCall
|
|
46
|
+
def initialize(context) = @context = context
|
|
47
|
+
def metadata = @context.metadata
|
|
48
|
+
def deadline = @context.deadline
|
|
49
|
+
def peer = @context.peer
|
|
50
|
+
def cancelled? = @context.cancelled?
|
|
51
|
+
def output_metadata = @context.call.trailing_metadata
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
class Request
|
|
55
|
+
attr_reader :context, :active_call, :error
|
|
56
|
+
|
|
57
|
+
def initialize(context)
|
|
58
|
+
@rpc_context = context
|
|
59
|
+
@original = Gritz::Controller::Request.for(context)
|
|
60
|
+
@context = RequestContext.new
|
|
61
|
+
@active_call = ActiveCall.new(context)
|
|
62
|
+
@error = Error.new
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def service = @rpc_context.method.service_class
|
|
66
|
+
def method_key = @rpc_context.method.action
|
|
67
|
+
def request_class = @rpc_context.method.input_type
|
|
68
|
+
def response_class = @rpc_context.method.output_type
|
|
69
|
+
def metadata = @rpc_context.metadata
|
|
70
|
+
def type = self
|
|
71
|
+
def client_streamer? = @rpc_context.method.kind == :client_streaming
|
|
72
|
+
def server_streamer? = @rpc_context.method.kind == :server_streaming
|
|
73
|
+
def bidi_streamer? = @rpc_context.method.kind == :bidi
|
|
74
|
+
def request_response? = @rpc_context.method.kind == :unary
|
|
75
|
+
|
|
76
|
+
def service_key
|
|
77
|
+
name = service.name || @rpc_context.method.service
|
|
78
|
+
name.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2').gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
79
|
+
.gsub("::", ".").downcase.delete_suffix(".service")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def method_name = "#{service_key}.#{method_key}"
|
|
83
|
+
|
|
84
|
+
def message
|
|
85
|
+
return @original.message unless @rpc_context.method.client_streaming?
|
|
86
|
+
|
|
87
|
+
@message ||= client_streamer? ? proc { |&block| @original.each_message(&block) } : @original.each_message
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def messages(&block)
|
|
91
|
+
return [message] unless @rpc_context.method.client_streaming?
|
|
92
|
+
|
|
93
|
+
block ? @original.each_message(&block) : @original.each_message
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
class Error
|
|
98
|
+
Field = Struct.new(:field_name, :error_code, :message)
|
|
99
|
+
DebugInfo = Struct.new(:detail, :stack_trace)
|
|
100
|
+
attr_accessor :code, :app_code, :message
|
|
101
|
+
attr_reader :field_errors, :debug_info, :metadata
|
|
102
|
+
|
|
103
|
+
def initialize
|
|
104
|
+
@field_errors = []
|
|
105
|
+
@metadata = {}
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def add_field_error(field, code, message = "") = @field_errors << Field.new(field, code, message)
|
|
109
|
+
def has_field_errors? = !@field_errors.empty? # rubocop:disable Naming/PredicatePrefix -- Preserve the upstream Gruf method name.
|
|
110
|
+
|
|
111
|
+
def set_debug_info(detail, stack_trace = [])
|
|
112
|
+
@debug_info = DebugInfo.new(detail, stack_trace.is_a?(String) ? stack_trace.split("\n") : Array(stack_trace))
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def metadata=(value)
|
|
116
|
+
@metadata = value.transform_values(&:to_s)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def to_h
|
|
120
|
+
{ code:, app_code:, message:, field_errors: field_errors.map(&:to_h), debug_info: debug_info.to_h }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def fail!(_active_call = nil)
|
|
124
|
+
canonical = { bad_request: :invalid_argument, unauthorized: :permission_denied }.fetch(code, code)
|
|
125
|
+
trailers = metadata.merge("error-internal-bin" => JSON.generate(to_h))
|
|
126
|
+
raise Gritz::Errors.for_code(canonical).new(message, metadata: trailers)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
module ErrorHelpers
|
|
131
|
+
def add_field_error(...) = error.add_field_error(...)
|
|
132
|
+
def has_field_errors? = error.has_field_errors? # rubocop:disable Naming/PredicatePrefix -- Preserve the upstream Gruf method name.
|
|
133
|
+
def set_debug_info(...) = error.set_debug_info(...)
|
|
134
|
+
|
|
135
|
+
def fail!(code, app_code = nil, message = "", metadata = {})
|
|
136
|
+
error.code = code.to_sym
|
|
137
|
+
error.app_code = app_code ? app_code.to_sym : error.code
|
|
138
|
+
error.message = message.to_s
|
|
139
|
+
error.metadata = metadata
|
|
140
|
+
error.fail!(request.active_call)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Controller base retaining selected Gruf request, error and streaming APIs.
|
|
145
|
+
# @api public
|
|
146
|
+
class Controller < Gritz::Controller
|
|
147
|
+
include ErrorHelpers
|
|
148
|
+
|
|
149
|
+
def self.bound_service = service_class
|
|
150
|
+
|
|
151
|
+
def self.action_defined?(action)
|
|
152
|
+
super && ![Controller, ErrorHelpers].include?(instance_method(action).owner)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def initialize(context:)
|
|
156
|
+
super
|
|
157
|
+
@request = Gruf.request_for(context)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def error = request.error
|
|
161
|
+
|
|
162
|
+
def process(action)
|
|
163
|
+
result = super
|
|
164
|
+
return result unless context.method.server_streaming?
|
|
165
|
+
|
|
166
|
+
result.each { |message| stream.write(message) } if result.respond_to?(:each)
|
|
167
|
+
nil
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
Base = Controller
|
|
172
|
+
|
|
173
|
+
# Base for migrated interceptors whose #call yields to the next handler.
|
|
174
|
+
# @api public
|
|
175
|
+
class ServerInterceptor
|
|
176
|
+
include ErrorHelpers
|
|
177
|
+
|
|
178
|
+
attr_reader :request, :error, :options
|
|
179
|
+
|
|
180
|
+
def initialize(request, error, options = {})
|
|
181
|
+
@request = request
|
|
182
|
+
@error = error
|
|
183
|
+
@options = options
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
class InterceptorAdapter
|
|
188
|
+
def initialize(app, klass, options)
|
|
189
|
+
@app = app
|
|
190
|
+
@klass = klass
|
|
191
|
+
@options = options
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def call(context)
|
|
195
|
+
request = Gruf.request_for(context)
|
|
196
|
+
@klass.new(request, request.error, @options.dup).call { @app.call(context) }
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
data/lib/gritz/configuration.rb
CHANGED
|
@@ -10,7 +10,7 @@ module Gritz
|
|
|
10
10
|
workers: 0, threads: 16, max_waiting_requests: 64,
|
|
11
11
|
transport: :native, listener_strategy: :reuseport,
|
|
12
12
|
bind: "0.0.0.0:50051", admin_bind: "127.0.0.1:9090",
|
|
13
|
-
fork_mode: :clean, fork_guard: :raise, strict_routes: false,
|
|
13
|
+
fork_mode: :clean, fork_guard: :raise, strict_routes: false, reflection: false,
|
|
14
14
|
drain_delay: 5.0, shutdown_timeout: 25.0, worker_boot_timeout: 60.0,
|
|
15
15
|
worker_timeout: 30.0, status_interval: 1.0, min_ready_workers: 1,
|
|
16
16
|
phased_restart_surge: 1, max_connection_age: 300.0,
|
|
@@ -117,8 +117,11 @@ module Gritz
|
|
|
117
117
|
# Fail before allocating transport resources for unsupported release features.
|
|
118
118
|
def validate_runtime!
|
|
119
119
|
validate!
|
|
120
|
-
|
|
121
|
-
raise ConfigurationError, "
|
|
120
|
+
supported = transport == :native ? %i[reuseport] : %i[reuseport inherited_fd]
|
|
121
|
+
raise ConfigurationError, "#{transport} does not support listener_strategy #{listener_strategy}" unless supported.include?(listener_strategy)
|
|
122
|
+
if transport != :native && fork_mode == :grpc_fork_support
|
|
123
|
+
raise ConfigurationError, "grpc_fork_support requires transport :native"
|
|
124
|
+
end
|
|
122
125
|
unless metrics_backend == :pipe || (metrics_backend == :otlp && metrics_recorder_factory)
|
|
123
126
|
raise ConfigurationError, "metrics_backend requires an installed recorder (:pipe is built in)"
|
|
124
127
|
end
|
|
@@ -126,7 +129,9 @@ module Gritz
|
|
|
126
129
|
|
|
127
130
|
unless worker_recycle.empty?
|
|
128
131
|
raise ConfigurationError, "worker_recycle requires workers > 0" unless workers.positive?
|
|
129
|
-
|
|
132
|
+
if bind.end_with?(":0") && listener_strategy != :inherited_fd
|
|
133
|
+
raise ConfigurationError, "worker_recycle requires a fixed bind port"
|
|
134
|
+
end
|
|
130
135
|
end
|
|
131
136
|
raise ConfigurationError, "grpc_fork_support requires workers > 0" if workers.zero? && fork_mode != :clean
|
|
132
137
|
raise ConfigurationError, "at least one controller must be registered" if controllers.empty?
|
|
@@ -148,6 +153,7 @@ module Gritz
|
|
|
148
153
|
|
|
149
154
|
def preload!
|
|
150
155
|
@preloaders.each(&:call)
|
|
156
|
+
Core::LOADER.eager_load
|
|
151
157
|
end
|
|
152
158
|
|
|
153
159
|
def add_hook(name, &block)
|
data/lib/gritz/controller.rb
CHANGED
|
@@ -42,7 +42,7 @@ module Gritz
|
|
|
42
42
|
|
|
43
43
|
def initialize(context:)
|
|
44
44
|
@context = context
|
|
45
|
-
@request = Request.
|
|
45
|
+
@request = Request.for(context)
|
|
46
46
|
@stream = Stream.new(context.call, context)
|
|
47
47
|
end
|
|
48
48
|
|
|
@@ -93,6 +93,13 @@ module Gritz
|
|
|
93
93
|
|
|
94
94
|
# @api public
|
|
95
95
|
class Request
|
|
96
|
+
STORE_KEY = Object.new.freeze
|
|
97
|
+
|
|
98
|
+
# @api private
|
|
99
|
+
def self.for(context)
|
|
100
|
+
context.store[STORE_KEY] ||= new(context.call, context)
|
|
101
|
+
end
|
|
102
|
+
|
|
96
103
|
def initialize(call, context)
|
|
97
104
|
@call = call
|
|
98
105
|
@context = context
|
data/lib/gritz/core/version.rb
CHANGED
data/lib/gritz/core.rb
CHANGED
|
@@ -13,7 +13,8 @@ module Gritz
|
|
|
13
13
|
LOADER.inflector.inflect("dsl" => "DSL", "cli" => "CLI")
|
|
14
14
|
LOADER.push_dir(__dir__, namespace: Gritz)
|
|
15
15
|
LOADER.ignore(__FILE__, File.join(__dir__, "errors.rb"), File.join(__dir__, "core/version.rb"))
|
|
16
|
-
LOADER.do_not_eager_load(File.join(__dir__, "testing/rspec.rb"), File.join(__dir__, "testing/minitest.rb")
|
|
16
|
+
LOADER.do_not_eager_load(File.join(__dir__, "testing/rspec.rb"), File.join(__dir__, "testing/minitest.rb"),
|
|
17
|
+
File.join(__dir__, "testing/transport_contract.rb"))
|
|
17
18
|
LOADER.setup
|
|
18
19
|
end
|
|
19
20
|
end
|
|
@@ -27,6 +27,8 @@ module Gritz
|
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
case e.class.name
|
|
30
|
+
when "Async::GRPC::DeadlineExceededError"
|
|
31
|
+
raise Errors::DeadlineExceeded, "deadline exceeded"
|
|
30
32
|
when "ActiveRecord::RecordNotFound"
|
|
31
33
|
raise Errors::NotFound, "record not found"
|
|
32
34
|
when "ActiveRecord::RecordInvalid"
|
|
@@ -23,6 +23,7 @@ module Gritz
|
|
|
23
23
|
@metrics = metrics
|
|
24
24
|
@logger = logger
|
|
25
25
|
@clients = {}
|
|
26
|
+
@read_buffer = +""
|
|
26
27
|
end
|
|
27
28
|
|
|
28
29
|
def ios = [@io, *@clients.keys]
|
|
@@ -68,7 +69,7 @@ module Gritz
|
|
|
68
69
|
end
|
|
69
70
|
|
|
70
71
|
def read_request(socket, client)
|
|
71
|
-
chunk = socket.read_nonblock(4096, exception: false)
|
|
72
|
+
chunk = socket.read_nonblock(4096, @read_buffer, exception: false)
|
|
72
73
|
return if chunk == :wait_readable
|
|
73
74
|
return drop(socket) if chunk.nil?
|
|
74
75
|
|