kino 0.2.1 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +72 -0
- data/Cargo.lock +192 -52
- data/README.md +131 -16
- data/exe/kino +0 -4
- data/ext/kino/Cargo.toml +4 -2
- data/ext/kino/src/access_log.rs +245 -0
- data/ext/kino/src/control.rs +701 -0
- data/ext/kino/src/cpus.rs +37 -0
- data/ext/kino/src/lib.rs +22 -1
- data/ext/kino/src/listen.rs +134 -0
- data/ext/kino/src/log.rs +144 -0
- data/ext/kino/src/mono.rs +26 -0
- data/ext/kino/src/queue.rs +11 -0
- data/ext/kino/src/registry.rs +158 -0
- data/ext/kino/src/request.rs +52 -2
- data/ext/kino/src/server.rs +229 -53
- data/ext/kino/src/style.rs +42 -39
- data/lib/kino/cli.rb +23 -9
- data/lib/kino/configuration.rb +77 -5
- data/lib/kino/errors_stream.rb +4 -3
- data/lib/kino/hook_fire.rb +22 -0
- data/lib/kino/log.rb +104 -0
- data/lib/kino/quarantine_monitor.rb +73 -0
- data/lib/kino/ractor_supervisor.rb +66 -16
- data/lib/kino/server.rb +201 -34
- data/lib/kino/templates/kino.rb.tt +62 -5
- data/lib/kino/version.rb +1 -1
- data/lib/kino/worker.rb +46 -30
- data/lib/kino/worker_hooks.rb +13 -0
- data/lib/kino.rb +14 -0
- data/lib/rackup/handler/kino.rb +88 -0
- data/sig/kino.rbs +71 -1
- metadata +26 -1
data/lib/kino/cli.rb
CHANGED
|
@@ -100,11 +100,14 @@ module Kino
|
|
|
100
100
|
end.join
|
|
101
101
|
end
|
|
102
102
|
|
|
103
|
-
# One-line stats dump (the SIGUSR1 handler
|
|
103
|
+
# One-line stats dump (what the SIGUSR1 handler logs). Excludes
|
|
104
|
+
# worker_status: it's an array with one entry per execution slot, and
|
|
105
|
+
# printing it inline would break the one-line contract (see /stats for
|
|
106
|
+
# per-worker detail).
|
|
104
107
|
# @param stats [Hash{Symbol => Object}] see {Kino::Server#stats}
|
|
105
108
|
# @return [String]
|
|
106
109
|
def stats_line(stats)
|
|
107
|
-
|
|
110
|
+
"stats #{stats.except(:worker_status).map { |k, v| "#{k}=#{v.inspect}" }.join(" ")}"
|
|
108
111
|
end
|
|
109
112
|
|
|
110
113
|
# The two banner halves around Server#start: credits before, the ready
|
|
@@ -116,15 +119,27 @@ module Kino
|
|
|
116
119
|
puts dim("\nKino #{VERSION} presents:")
|
|
117
120
|
end
|
|
118
121
|
|
|
122
|
+
# The ready block: what this process is (Ruby build with its JIT and
|
|
123
|
+
# parser flags, environment, topology, pid) and where it listens.
|
|
119
124
|
# @param server [Kino::Server] a started server
|
|
120
125
|
# @return [void]
|
|
121
126
|
def action!(server)
|
|
122
|
-
|
|
123
|
-
puts dim("-
|
|
127
|
+
stats = server.stats
|
|
128
|
+
puts dim("- ruby: #{RUBY_DESCRIPTION}")
|
|
129
|
+
puts dim("- env: #{ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "development"}")
|
|
130
|
+
puts dim("- mode: #{server.mode}, #{count(stats[:workers], "worker")} × #{count(stats[:threads], "thread")}")
|
|
131
|
+
puts dim("- pid: #{Process.pid}")
|
|
132
|
+
puts dim("- listening: #{server.url}")
|
|
133
|
+
puts dim("- control: #{server.control_url}") if server.control_url
|
|
124
134
|
puts dim("- Ctrl-C to drain and stop")
|
|
125
135
|
puts "\n#{bold("Action!")}\n\n"
|
|
126
136
|
end
|
|
127
137
|
|
|
138
|
+
# "1 worker", "8 workers".
|
|
139
|
+
def count(number, noun)
|
|
140
|
+
"#{number} #{noun}#{"s" unless number == 1}"
|
|
141
|
+
end
|
|
142
|
+
|
|
128
143
|
# Roll credits when the process ends: normal exit or crash (at_exit
|
|
129
144
|
# also runs after an uncaught exception; only a force-exit skips it).
|
|
130
145
|
# @return [void]
|
|
@@ -197,7 +212,7 @@ module Kino
|
|
|
197
212
|
def option_parser(options)
|
|
198
213
|
OptionParser.new do |opts|
|
|
199
214
|
opts.banner = "Usage: kino [options] [rackup file (default: config.ru)]"
|
|
200
|
-
opts.on("-C", "--config FILE", "Config file (default: kino.rb
|
|
215
|
+
opts.on("-C", "--config FILE", "Config file (default: kino.rb, then config/kino.rb)") { |v| options[:config_file] = v }
|
|
201
216
|
opts.on("--init [PATH]", "Write a commented sample config (default: kino.rb) and exit") do |v|
|
|
202
217
|
options[:init_path] = v || "kino.rb"
|
|
203
218
|
end
|
|
@@ -233,14 +248,13 @@ module Kino
|
|
|
233
248
|
require "kino"
|
|
234
249
|
require "rack"
|
|
235
250
|
|
|
236
|
-
config_file = options[:config_file]
|
|
237
|
-
config_file ||= ("kino.rb" if File.exist?("kino.rb"))
|
|
251
|
+
config_file = options[:config_file] || Configuration.default_path
|
|
238
252
|
|
|
239
253
|
config = Configuration.new
|
|
240
254
|
config.load_file(config_file) if config_file
|
|
241
255
|
config.merge!(options[:overrides])
|
|
242
256
|
# Default port 9292 when neither the file nor a flag chose one.
|
|
243
|
-
config.set(:port,
|
|
257
|
+
config.set(:port, Configuration::DEFAULT_SERVING_PORT) unless config.set?(:port)
|
|
244
258
|
config
|
|
245
259
|
end
|
|
246
260
|
|
|
@@ -249,6 +263,6 @@ module Kino
|
|
|
249
263
|
end
|
|
250
264
|
|
|
251
265
|
private_class_method :print_help, :option_parser, :write_sample,
|
|
252
|
-
:resolve_config, :serve
|
|
266
|
+
:resolve_config, :serve, :count
|
|
253
267
|
end
|
|
254
268
|
end
|
data/lib/kino/configuration.rb
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "etc"
|
|
4
|
-
|
|
5
3
|
module Kino
|
|
6
4
|
# Server settings with Puma-style precedence:
|
|
7
5
|
# explicit Server.new kwargs > config file DSL > defaults.
|
|
@@ -11,7 +9,7 @@ module Kino
|
|
|
11
9
|
DEFAULTS = {
|
|
12
10
|
bind: "127.0.0.1",
|
|
13
11
|
port: 0,
|
|
14
|
-
workers: nil, # resolved to
|
|
12
|
+
workers: nil, # resolved to Kino.available_parallelism in #to_h
|
|
15
13
|
threads: nil, # resolved per mode in Server: 1 in :ractor, 3 in :threaded
|
|
16
14
|
mode: :auto,
|
|
17
15
|
queue_depth: 1024,
|
|
@@ -23,11 +21,19 @@ module Kino
|
|
|
23
21
|
lanes: false,
|
|
24
22
|
log_requests: false,
|
|
25
23
|
on_error: nil,
|
|
24
|
+
after_boot: nil,
|
|
25
|
+
after_worker_boot: nil,
|
|
26
|
+
after_request_complete: nil,
|
|
27
|
+
on_worker_exit: nil,
|
|
26
28
|
shutdown_timeout: 30,
|
|
27
29
|
tokio_threads: nil,
|
|
28
30
|
tls: nil,
|
|
29
31
|
environment: nil,
|
|
30
32
|
pidfile: nil,
|
|
33
|
+
control_bind: nil,
|
|
34
|
+
control_token: nil,
|
|
35
|
+
quarantine_timeout: nil,
|
|
36
|
+
quarantine_max: nil,
|
|
31
37
|
rackup: nil
|
|
32
38
|
}.freeze
|
|
33
39
|
|
|
@@ -37,6 +43,21 @@ module Kino
|
|
|
37
43
|
# Source template for {.sample}.
|
|
38
44
|
SAMPLE_TEMPLATE = File.expand_path("templates/kino.rb.tt", __dir__)
|
|
39
45
|
|
|
46
|
+
# Where the `kino` CLI and the Rack handler look for a config file when
|
|
47
|
+
# none is named: the project root first, then the Rails-style config/.
|
|
48
|
+
DEFAULT_PATHS = %w[kino.rb config/kino.rb].freeze
|
|
49
|
+
|
|
50
|
+
# The port the CLI and the Rack handler serve on when neither a flag
|
|
51
|
+
# nor the file chose one (Server.new itself defaults to an ephemeral
|
|
52
|
+
# port, for embedding).
|
|
53
|
+
DEFAULT_SERVING_PORT = 9292
|
|
54
|
+
|
|
55
|
+
# The first of {DEFAULT_PATHS} that exists in the working directory.
|
|
56
|
+
# @return [String, nil]
|
|
57
|
+
def self.default_path
|
|
58
|
+
DEFAULT_PATHS.find { |path| File.exist?(path) }
|
|
59
|
+
end
|
|
60
|
+
|
|
40
61
|
# The fully-commented sample config (see `kino --init`).
|
|
41
62
|
# @return [String]
|
|
42
63
|
def self.sample
|
|
@@ -105,7 +126,7 @@ module Kino
|
|
|
105
126
|
# @return [Hash{Symbol => Object}] every setting, defaults filled in
|
|
106
127
|
def to_h
|
|
107
128
|
SETTINGS.to_h { |key| [key, self[key]] }.tap do |h|
|
|
108
|
-
h[:workers] ||=
|
|
129
|
+
h[:workers] ||= Kino.available_parallelism
|
|
109
130
|
end
|
|
110
131
|
end
|
|
111
132
|
|
|
@@ -138,7 +159,9 @@ module Kino
|
|
|
138
159
|
@config = config
|
|
139
160
|
end
|
|
140
161
|
|
|
141
|
-
# Address to listen on ("0.0.0.0" accepts non-local
|
|
162
|
+
# Address to listen on: a host ("0.0.0.0" accepts non-local
|
|
163
|
+
# connections), or "unix:///path/to.sock" for a unix domain socket
|
|
164
|
+
# (then `port` is unused).
|
|
142
165
|
def bind(host) = @config.set(:bind, host)
|
|
143
166
|
|
|
144
167
|
# Port to listen on; 0 picks an ephemeral port.
|
|
@@ -185,6 +208,24 @@ module Kino
|
|
|
185
208
|
# or a block. Must be Ractor-shareable in :ractor mode.
|
|
186
209
|
def on_error(handler = nil, &block) = @config.set(:on_error, handler || block)
|
|
187
210
|
|
|
211
|
+
# Called once on the main thread after the worker pool is up. The
|
|
212
|
+
# readiness seam (wire sd_notify or a "server ready" metric here).
|
|
213
|
+
def after_boot(handler = nil, &block) = @config.set(:after_boot, handler || block)
|
|
214
|
+
|
|
215
|
+
# Called once inside each worker (a ractor in :ractor mode) before it
|
|
216
|
+
# serves, with the worker's slot id. Must be Ractor-shareable in
|
|
217
|
+
# :ractor mode (build it with Ractor.shareable_proc).
|
|
218
|
+
def after_worker_boot(handler = nil, &block) = @config.set(:after_worker_boot, handler || block)
|
|
219
|
+
|
|
220
|
+
# Called inside the worker after each successful response with
|
|
221
|
+
# (env, status). Hot path: leave unset for zero cost. Must be
|
|
222
|
+
# Ractor-shareable in :ractor mode.
|
|
223
|
+
def after_request_complete(handler = nil, &block) = @config.set(:after_request_complete, handler || block)
|
|
224
|
+
|
|
225
|
+
# Called on the main thread when a worker exits, with (worker_index,
|
|
226
|
+
# error_or_nil). error is the crash cause, or nil on a clean exit.
|
|
227
|
+
def on_worker_exit(handler = nil, &block) = @config.set(:on_worker_exit, handler || block)
|
|
228
|
+
|
|
188
229
|
# Graceful-shutdown drain deadline in seconds.
|
|
189
230
|
def shutdown_timeout(seconds) = @config.set(:shutdown_timeout, seconds)
|
|
190
231
|
|
|
@@ -200,6 +241,37 @@ module Kino
|
|
|
200
241
|
# Write the master PID here on start.
|
|
201
242
|
def pidfile(path) = @config.set(:pidfile, path.to_s)
|
|
202
243
|
|
|
244
|
+
# Serve the read-only control plane (live stats as JSON at /stats,
|
|
245
|
+
# Prometheus text at /metrics, /ready and /live probes) on this
|
|
246
|
+
# address: "host:port" or "unix://path". Off unless set.
|
|
247
|
+
def control_bind(addr) = @config.set(:control_bind, addr.to_s)
|
|
248
|
+
|
|
249
|
+
# When set, /stats and /metrics require "Authorization: Bearer <token>".
|
|
250
|
+
# The probes stay open; they carry no data.
|
|
251
|
+
def control_token(token) = @config.set(:control_token, token.to_s)
|
|
252
|
+
|
|
253
|
+
# Quarantine a dispatch slot whose current request has run longer
|
|
254
|
+
# than this many seconds, spawning a replacement to restore capacity.
|
|
255
|
+
# Off unless set. Set it above your slowest legitimate endpoint (and
|
|
256
|
+
# typically above request_timeout).
|
|
257
|
+
def quarantine_timeout(seconds)
|
|
258
|
+
seconds &&= Float(seconds)
|
|
259
|
+
if seconds && seconds <= 0
|
|
260
|
+
raise ArgumentError, "quarantine_timeout must be greater than 0 (got #{seconds})"
|
|
261
|
+
end
|
|
262
|
+
@config.set(:quarantine_timeout, seconds)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Cap on the total number of replacement events over the process
|
|
266
|
+
# lifetime. Past the cap the monitor stops replacing and the server
|
|
267
|
+
# runs at reduced capacity. Default: the worker count in :ractor
|
|
268
|
+
# mode, workers x threads in :threaded.
|
|
269
|
+
def quarantine_max(count)
|
|
270
|
+
count = Integer(count)
|
|
271
|
+
raise ArgumentError, "quarantine_max must be >= 1 (got #{count})" if count < 1
|
|
272
|
+
@config.set(:quarantine_max, count)
|
|
273
|
+
end
|
|
274
|
+
|
|
203
275
|
# Rackup file the `kino` CLI loads (positional argument wins).
|
|
204
276
|
def rackup(path) = @config.set(:rackup, path.to_s)
|
|
205
277
|
end
|
data/lib/kino/errors_stream.rb
CHANGED
|
@@ -2,17 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
module Kino
|
|
4
4
|
# @private
|
|
5
|
-
# rack.errors: stateless writer into the
|
|
5
|
+
# rack.errors: stateless writer into the server log (one line per
|
|
6
|
+
# call, labelled like every other line Kino writes). Frozen singleton,
|
|
6
7
|
# which also makes it Ractor-shareable; one instance serves all workers.
|
|
7
8
|
class ErrorsStream
|
|
8
9
|
def puts(message)
|
|
9
|
-
|
|
10
|
+
Log.error(message.to_s.chomp)
|
|
10
11
|
nil
|
|
11
12
|
end
|
|
12
13
|
|
|
13
14
|
def write(message)
|
|
14
15
|
message = message.to_s
|
|
15
|
-
|
|
16
|
+
Log.error(message.chomp)
|
|
16
17
|
message.bytesize
|
|
17
18
|
end
|
|
18
19
|
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kino
|
|
4
|
+
# @private
|
|
5
|
+
# Fires a lifecycle hook and turns a raise into a logged line instead of
|
|
6
|
+
# letting it escape. Stateless and touches only its arguments plus
|
|
7
|
+
# Kino::Log (safe inside worker ractors), so it is safe to call from
|
|
8
|
+
# worker context: no main-ractor state is captured.
|
|
9
|
+
module HookFire
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def fire(hook, name, *args)
|
|
13
|
+
return unless hook
|
|
14
|
+
|
|
15
|
+
begin
|
|
16
|
+
hook.call(*args)
|
|
17
|
+
rescue => e
|
|
18
|
+
Log.error("#{name} hook raised #{e.class}: #{e.message}")
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
data/lib/kino/log.rb
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kino
|
|
4
|
+
# Server log lines: the lifecycle notices, crash and respawn reports,
|
|
5
|
+
# hook failures, the failed-request report, and whatever apps write to
|
|
6
|
+
# `rack.errors`, all in one shape:
|
|
7
|
+
#
|
|
8
|
+
# kino[4213] worker-3: after_worker_boot hook raised RuntimeError: boom
|
|
9
|
+
#
|
|
10
|
+
# The label is syslog's `ident[pid]` tag plus the source that spoke: the
|
|
11
|
+
# worker ractor and/or thread by name, `main` for neither. On color
|
|
12
|
+
# terminals the label is dim, yellow, or red by level; the message stays
|
|
13
|
+
# plain. Notes go to stdout, warnings and errors to stderr.
|
|
14
|
+
#
|
|
15
|
+
# Hooks may log through here too (`Kino::Log.info "cache warm"`). Every
|
|
16
|
+
# method is safe inside a worker ractor: the line is handed to the
|
|
17
|
+
# native layer, which owns the streams, so no ractor touches `$stdout`
|
|
18
|
+
# or `$stderr` itself.
|
|
19
|
+
module Log
|
|
20
|
+
# Frames shown in a failed-request report before the rest are folded.
|
|
21
|
+
FRAMES = 12
|
|
22
|
+
|
|
23
|
+
# The working directory at boot, stripped from backtrace frames so the
|
|
24
|
+
# app's own code reads `app/controllers/x.rb:9` rather than an
|
|
25
|
+
# absolute path (frozen: worker ractors read it).
|
|
26
|
+
WORKING_DIR = File.join(Dir.pwd, "").freeze
|
|
27
|
+
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
# @param message [#to_s]
|
|
31
|
+
# @return [void]
|
|
32
|
+
def info(message)
|
|
33
|
+
Native.log_line("info", source, message.to_s)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @param message [#to_s]
|
|
37
|
+
# @return [void]
|
|
38
|
+
def warn(message)
|
|
39
|
+
Native.log_line("warn", source, message.to_s)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @param message [#to_s]
|
|
43
|
+
# @return [void]
|
|
44
|
+
def error(message)
|
|
45
|
+
Native.log_line("error", source, message.to_s)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The failed-request report: the request line, the error, and where it
|
|
49
|
+
# raised in the app, then the backtrace with the app's own frames
|
|
50
|
+
# first (relative to the working directory) and the rest folded.
|
|
51
|
+
#
|
|
52
|
+
# 500 GET /boom · RuntimeError: kaboom (app.rb:12:in 'explode')
|
|
53
|
+
# app.rb:12:in 'explode'
|
|
54
|
+
# /gems/rack-3.2.7/lib/rack/builder.rb:...
|
|
55
|
+
# … 38 more
|
|
56
|
+
#
|
|
57
|
+
# @param error [Exception]
|
|
58
|
+
# @param env [Hash] the Rack env of the failed request
|
|
59
|
+
# @param status [Integer] the status the client got
|
|
60
|
+
# @return [void]
|
|
61
|
+
def exception(error, env, status: 500)
|
|
62
|
+
frames, depth = trace(error)
|
|
63
|
+
site = frames.first ? " (#{frames.first})" : ""
|
|
64
|
+
lines = ["#{status} #{env["REQUEST_METHOD"]} #{env["PATH_INFO"]} · #{error.class}: #{error.message}#{site}"]
|
|
65
|
+
frames.each { |frame| lines << " #{frame}" }
|
|
66
|
+
lines << " … #{depth - frames.size} more" if depth > frames.size
|
|
67
|
+
error(lines.join("\n"))
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The `kino[<pid>] <source>:` tag a line from here carries.
|
|
71
|
+
# @return [String]
|
|
72
|
+
def label
|
|
73
|
+
"kino[#{Process.pid}] #{source}:"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Who is speaking: the ractor's name, the thread's name, both joined
|
|
77
|
+
# with a slash, or `main` when neither is named. Kino names its
|
|
78
|
+
# worker ractors and threads `worker-N`.
|
|
79
|
+
# @return [String]
|
|
80
|
+
def source
|
|
81
|
+
parts = [Ractor.current.name, Thread.current.name].compact
|
|
82
|
+
parts.empty? ? "main" : parts.join("/")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The backtrace as [frames, depth]: each frame relativized to the
|
|
86
|
+
# working directory, the app's own frames floated to the front (the
|
|
87
|
+
# raise site in your code reads first; gem and stdlib frames keep
|
|
88
|
+
# their order below), capped at FRAMES; depth is the real length.
|
|
89
|
+
def trace(error)
|
|
90
|
+
raw = error.backtrace || []
|
|
91
|
+
app, rest = raw.map { |frame| frame.delete_prefix(WORKING_DIR) }.partition { |frame| app_frame?(frame) }
|
|
92
|
+
[(app + rest).first(FRAMES), raw.size]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# A project-relative path (the working-directory prefix came off, so
|
|
96
|
+
# it does not start with `/`) that is not a synthetic frame (`(eval)`,
|
|
97
|
+
# `<internal:...>`). Gem and stdlib frames stay absolute.
|
|
98
|
+
def app_frame?(frame)
|
|
99
|
+
!frame.start_with?("/", "<", "(")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private_class_method :trace, :app_frame?
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kino
|
|
4
|
+
# @private
|
|
5
|
+
# Polls per-slot busy_ms and, past the timeout, quarantines a wedged slot
|
|
6
|
+
# and asks the replacer to spawn a fresh worker. Runs one thread on the
|
|
7
|
+
# main ractor (uncontended by wedged worker ractors, so it stays
|
|
8
|
+
# responsive in :ractor mode). Never interrupts the wedged worker.
|
|
9
|
+
class QuarantineMonitor
|
|
10
|
+
def initialize(server_id:, timeout_ms:, max:, replacer:, tick: 0.5)
|
|
11
|
+
@server_id = server_id
|
|
12
|
+
@timeout_ms = timeout_ms
|
|
13
|
+
@max = max
|
|
14
|
+
@replacer = replacer
|
|
15
|
+
@tick = tick
|
|
16
|
+
@outstanding = 0
|
|
17
|
+
@at_cap_logged = false
|
|
18
|
+
@running = false
|
|
19
|
+
@thread = nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def start
|
|
23
|
+
@running = true
|
|
24
|
+
@thread = Thread.new do
|
|
25
|
+
Thread.current.name = "quarantine"
|
|
26
|
+
run
|
|
27
|
+
end
|
|
28
|
+
self
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def stop
|
|
32
|
+
@running = false
|
|
33
|
+
@thread&.join(@tick * 2)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def run
|
|
39
|
+
tick while @running
|
|
40
|
+
rescue => e
|
|
41
|
+
Log.error("quarantine monitor crashed: #{e.class}: #{e.message}")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def tick
|
|
45
|
+
scan_slots
|
|
46
|
+
rescue => e
|
|
47
|
+
# A bad tick must never kill the monitor.
|
|
48
|
+
Log.error("quarantine tick error: #{e.class}: #{e.message}")
|
|
49
|
+
ensure
|
|
50
|
+
sleep @tick
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def scan_slots
|
|
54
|
+
Native.worker_stats(@server_id).each do |index, _served, _in_flight, busy_ms, quarantined|
|
|
55
|
+
next if quarantined || busy_ms <= @timeout_ms
|
|
56
|
+
|
|
57
|
+
if @outstanding >= @max
|
|
58
|
+
unless @at_cap_logged
|
|
59
|
+
Log.warn("quarantine at cap (#{@max}); serving at reduced capacity")
|
|
60
|
+
@at_cap_logged = true
|
|
61
|
+
end
|
|
62
|
+
next
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
if @replacer.replace(index)
|
|
66
|
+
Native.record_quarantine_replacement(@server_id)
|
|
67
|
+
@outstanding += 1
|
|
68
|
+
@at_cap_logged = false
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -7,19 +7,23 @@ module Kino
|
|
|
7
7
|
# ractor, Exception from app code included) wakes it to 500 the in-flight
|
|
8
8
|
# requests and respawn. Clean exits (queue drained) end supervision.
|
|
9
9
|
class RactorSupervisor
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def initialize(server_id, app, workers:, threads:, batch: 1, on_error: nil)
|
|
10
|
+
def initialize(server_id, app, workers:, threads:, batch: 1, hooks: nil, on_worker_exit: nil)
|
|
13
11
|
@server_id = server_id
|
|
14
12
|
@app = app
|
|
15
13
|
@workers = workers
|
|
16
14
|
@threads = threads
|
|
17
15
|
@batch = batch
|
|
18
|
-
@
|
|
19
|
-
@
|
|
16
|
+
@hooks = hooks
|
|
17
|
+
@on_worker_exit = on_worker_exit
|
|
20
18
|
@draining = false
|
|
21
19
|
@lock = Mutex.new
|
|
22
20
|
@supervisor_threads = []
|
|
21
|
+
@worker_slots = {}
|
|
22
|
+
@slot_to_worker = {}
|
|
23
|
+
@replaced = {}
|
|
24
|
+
# The first replacement's index; `replace` increments before using it,
|
|
25
|
+
# so this starts one below the first free index (@workers).
|
|
26
|
+
@next_worker_index = @workers - 1
|
|
23
27
|
end
|
|
24
28
|
|
|
25
29
|
def start
|
|
@@ -32,43 +36,81 @@ module Kino
|
|
|
32
36
|
def shutdown(timeout)
|
|
33
37
|
@lock.synchronize { @draining = true }
|
|
34
38
|
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
35
|
-
@supervisor_threads.each do |thread|
|
|
39
|
+
@lock.synchronize { @supervisor_threads.dup }.each do |thread|
|
|
36
40
|
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
37
41
|
thread.join([remaining, 0.01].max)
|
|
38
42
|
end
|
|
39
43
|
end
|
|
40
44
|
|
|
41
45
|
def done?
|
|
42
|
-
@supervisor_threads.none?(&:alive?)
|
|
46
|
+
@lock.synchronize { @supervisor_threads.dup }.none?(&:alive?)
|
|
43
47
|
end
|
|
44
48
|
|
|
45
49
|
# Block until the workers exit on their own (drain elsewhere): join
|
|
46
50
|
# without flipping the draining flag.
|
|
47
51
|
def join
|
|
48
|
-
@supervisor_threads.each(&:join)
|
|
52
|
+
@lock.synchronize { @supervisor_threads.dup }.each(&:join)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Replace the ractor owning slot `worker_id`: spawn a fresh supervised
|
|
56
|
+
# ractor, then quarantine the old ractor's slots. The old supervisor
|
|
57
|
+
# thread stays blocked in ractor.value on the wedged ractor (it and the
|
|
58
|
+
# ractor leak until process exit; a wedged ractor cannot be
|
|
59
|
+
# force-killed). Returns true if a replacement was spawned.
|
|
60
|
+
def replace(worker_id)
|
|
61
|
+
worker_index = @lock.synchronize { @slot_to_worker[worker_id] }
|
|
62
|
+
return false unless worker_index
|
|
63
|
+
|
|
64
|
+
# Idempotent per ractor: a stale monitor snapshot can list two sibling
|
|
65
|
+
# slots of the same ractor, only the first replaces it.
|
|
66
|
+
claimed = @lock.synchronize do
|
|
67
|
+
if @replaced.key?(worker_index)
|
|
68
|
+
false
|
|
69
|
+
else
|
|
70
|
+
@replaced[worker_index] = true
|
|
71
|
+
true
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
return false unless claimed
|
|
75
|
+
|
|
76
|
+
new_index = @lock.synchronize { @next_worker_index += 1 }
|
|
77
|
+
thread =
|
|
78
|
+
begin
|
|
79
|
+
supervise(new_index) # spawn FIRST, nothing quarantined yet
|
|
80
|
+
rescue
|
|
81
|
+
@lock.synchronize { @replaced.delete(worker_index) } # allow retry next tick
|
|
82
|
+
raise
|
|
83
|
+
end
|
|
84
|
+
slot_ids = @lock.synchronize { @worker_slots[worker_index] } || []
|
|
85
|
+
slot_ids.each { |id| Native.quarantine_slot(@server_id, id) } # quarantine only after success
|
|
86
|
+
@lock.synchronize { @supervisor_threads << thread }
|
|
87
|
+
true
|
|
49
88
|
end
|
|
50
89
|
|
|
51
90
|
private
|
|
52
91
|
|
|
53
92
|
def supervise(index)
|
|
54
93
|
Thread.new do
|
|
94
|
+
Thread.current.name = "supervisor-#{index}"
|
|
55
95
|
crashes = 0
|
|
56
96
|
loop do
|
|
57
|
-
ractor, worker_ids = spawn_worker
|
|
97
|
+
ractor, worker_ids = spawn_worker(index)
|
|
58
98
|
begin
|
|
59
99
|
ractor.value # blocks until the ractor terminates
|
|
100
|
+
HookFire.fire(@on_worker_exit, "on_worker_exit", index, nil) # clean exit: queue drained
|
|
60
101
|
break # clean exit: queue closed, workers drained
|
|
61
102
|
rescue Ractor::Error => e
|
|
62
103
|
# The ractor died mid-flight. Anything it was serving will never
|
|
63
104
|
# be answered by Ruby: 500 those clients NOW (not when GC gets
|
|
64
105
|
# around to dropping the dead heap), then decide on respawn.
|
|
65
106
|
worker_ids.each { |id| Native.abort_inflight(@server_id, id) }
|
|
107
|
+
cause = (e.respond_to?(:cause) && e.cause) ? e.cause : e
|
|
108
|
+
HookFire.fire(@on_worker_exit, "on_worker_exit", index, cause)
|
|
66
109
|
break if draining?
|
|
67
110
|
|
|
68
111
|
crashes += 1
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
Native.log_error("worker ractor #{index} crashed (#{cause.class}: #{cause.message}); respawning")
|
|
112
|
+
Native.record_respawn(@server_id)
|
|
113
|
+
Log.error("worker-#{index} crashed (#{cause.class}: #{cause.message}); respawning")
|
|
72
114
|
# Policy (crash recovery): unlimited respawn
|
|
73
115
|
# keeps the server up under rare crashes but turns a
|
|
74
116
|
# crash-on-every-request bug into a busy loop. A circuit breaker
|
|
@@ -82,15 +124,23 @@ module Kino
|
|
|
82
124
|
# Fresh ractor + fresh native slots. Slots are never reused across
|
|
83
125
|
# respawns: stale interrupt kicks and dead weak refs go down with the
|
|
84
126
|
# old slot.
|
|
85
|
-
def spawn_worker
|
|
127
|
+
def spawn_worker(worker_index)
|
|
86
128
|
worker_ids = Array.new(@threads) { Native.register_worker(@server_id) }
|
|
87
|
-
|
|
88
|
-
|
|
129
|
+
@lock.synchronize do
|
|
130
|
+
@worker_slots[worker_index] = worker_ids
|
|
131
|
+
worker_ids.each { |id| @slot_to_worker[id] = worker_index }
|
|
132
|
+
end
|
|
133
|
+
# Named so log lines from inside say which worker spoke: the ractor
|
|
134
|
+
# alone for a single thread, `worker-N/thread-M` for more.
|
|
135
|
+
ractor = Ractor.new(@server_id, worker_ids, @app, @batch, @hooks,
|
|
136
|
+
name: "worker-#{worker_index}") do |server_id, ids, app, batch, hooks|
|
|
137
|
+
ids.each_with_index.map do |id, position|
|
|
89
138
|
Thread.new do
|
|
90
139
|
# Crashes surface via Ractor#value in the supervisor; don't also
|
|
91
140
|
# spray the backtrace to stderr from inside the dying ractor.
|
|
92
141
|
Thread.current.report_on_exception = false
|
|
93
|
-
|
|
142
|
+
Thread.current.name = "thread-#{position + 1}" if ids.size > 1
|
|
143
|
+
Kino::Worker.run(server_id, id, app, batch, hooks)
|
|
94
144
|
end
|
|
95
145
|
end.each(&:join)
|
|
96
146
|
end
|