kino 0.5.0 → 0.7.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 +67 -0
- data/Cargo.lock +92 -1
- data/README.md +153 -44
- data/doc/architecture.md +64 -3
- data/doc/benchmarks.md +120 -26
- data/ext/kino/Cargo.toml +8 -3
- data/ext/kino/src/control.rs +103 -6
- data/ext/kino/src/env_strings.rs +350 -69
- data/ext/kino/src/lib.rs +22 -7
- data/ext/kino/src/pin.rs +21 -9
- data/ext/kino/src/queue.rs +158 -46
- data/ext/kino/src/registry.rs +145 -0
- data/ext/kino/src/request.rs +151 -70
- data/ext/kino/src/response.rs +39 -5
- data/ext/kino/src/server.rs +864 -33
- data/ext/kino/src/test_support.rs +44 -0
- data/ext/kino/src/tls.rs +119 -9
- data/lib/kino/cli.rb +12 -4
- data/lib/kino/configuration.rb +22 -2
- data/lib/kino/monitor.rb +52 -0
- data/lib/kino/pool_scaler.rb +103 -0
- data/lib/kino/quarantine_monitor.rb +5 -37
- data/lib/kino/ractor_supervisor.rb +102 -8
- data/lib/kino/server.rb +66 -85
- data/lib/kino/slot_bank.rb +31 -0
- data/lib/kino/templates/kino.rb.tt +20 -0
- data/lib/kino/threaded_pool.rb +186 -0
- data/lib/kino/version.rb +1 -1
- data/lib/kino/worker.rb +14 -11
- data/lib/kino.rb +4 -0
- data/lib/rackup/handler/kino.rb +4 -4
- data/sig/kino.rbs +3 -0
- metadata +19 -1
|
@@ -5,7 +5,12 @@ module Kino
|
|
|
5
5
|
# Spawns worker ractors and keeps them alive. One supervisor thread per
|
|
6
6
|
# ractor: it blocks in Ractor#value, and a crash (anything that kills the
|
|
7
7
|
# ractor, Exception from app code included) wakes it to 500 the in-flight
|
|
8
|
-
# requests and respawn. Clean exits (queue drained
|
|
8
|
+
# requests and respawn. Clean exits (queue drained at shutdown, or the
|
|
9
|
+
# worker retired by the pool scaler) end supervision.
|
|
10
|
+
#
|
|
11
|
+
# Also the :ractor-mode pool behind PoolScaler: `grow` adds a worker,
|
|
12
|
+
# `retire` sends one home, `groups` lists the ones that may be retired,
|
|
13
|
+
# and `active_count` is what the control plane reports.
|
|
9
14
|
class RactorSupervisor
|
|
10
15
|
def initialize(server_id, app, workers:, threads:, batch: 1, hooks: nil, on_worker_exit: nil)
|
|
11
16
|
@server_id = server_id
|
|
@@ -21,6 +26,12 @@ module Kino
|
|
|
21
26
|
@worker_slots = {}
|
|
22
27
|
@slot_to_worker = {}
|
|
23
28
|
@replaced = {}
|
|
29
|
+
# Worker index => true while its ractor runs, and => true once the
|
|
30
|
+
# scaler asked it to leave. Retired workers hand their slots back to
|
|
31
|
+
# the bank for the next worker to take over.
|
|
32
|
+
@live = {}
|
|
33
|
+
@retiring = {}
|
|
34
|
+
@bank = SlotBank.new(server_id)
|
|
24
35
|
# The first replacement's index; `replace` increments before using it,
|
|
25
36
|
# so this starts one below the first free index (@workers).
|
|
26
37
|
@next_worker_index = @workers - 1
|
|
@@ -28,6 +39,7 @@ module Kino
|
|
|
28
39
|
|
|
29
40
|
def start
|
|
30
41
|
@supervisor_threads = Array.new(@workers) { |index| supervise(index) }
|
|
42
|
+
report_active
|
|
31
43
|
self
|
|
32
44
|
end
|
|
33
45
|
|
|
@@ -52,6 +64,61 @@ module Kino
|
|
|
52
64
|
@lock.synchronize { @supervisor_threads.dup }.each(&:join)
|
|
53
65
|
end
|
|
54
66
|
|
|
67
|
+
# Ractors cannot be force-killed; their clients were already freed by
|
|
68
|
+
# abort_all_inflight. A stuck ractor leaks until process exit.
|
|
69
|
+
def kill_stragglers
|
|
70
|
+
Log.error("shutdown deadline passed with stuck ractor workers") unless done?
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Workers that are staying: the live ones minus those the quarantine
|
|
74
|
+
# monitor abandoned as wedged (their replacements count instead) and
|
|
75
|
+
# minus those already told to leave. A retiring worker leaves the
|
|
76
|
+
# count at once, not when its ractor finally exits, so the scaler
|
|
77
|
+
# never sees a stale surplus and retires past its floor.
|
|
78
|
+
def active_count
|
|
79
|
+
@lock.synchronize do
|
|
80
|
+
@live.count { |index, _| !@replaced.key?(index) && !@retiring.key?(index) }
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Worker index => slot ids for every worker the scaler may retire:
|
|
85
|
+
# live, not already leaving, not quarantined, and past its spawn (a
|
|
86
|
+
# worker marked live whose supervisor thread has not assigned slots
|
|
87
|
+
# yet is not listed until it has).
|
|
88
|
+
def groups
|
|
89
|
+
@lock.synchronize do
|
|
90
|
+
@live.keys
|
|
91
|
+
.reject { |index| @retiring.key?(index) || @replaced.key?(index) || !@worker_slots.key?(index) }
|
|
92
|
+
.to_h { |index| [index, @worker_slots[index].dup] }
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Add one supervised worker; returns its index.
|
|
97
|
+
def grow
|
|
98
|
+
new_index = @lock.synchronize { @next_worker_index += 1 }
|
|
99
|
+
thread = supervise(new_index)
|
|
100
|
+
@lock.synchronize { @supervisor_threads << thread }
|
|
101
|
+
report_active
|
|
102
|
+
new_index
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Send a worker home. Its slots stop receiving work now; the worker
|
|
106
|
+
# finishes what it holds, leaves at its next idle tick, and its slots
|
|
107
|
+
# come back to the free list once the ractor has exited. Returns
|
|
108
|
+
# false when there is no such live worker to retire.
|
|
109
|
+
def retire(worker_index)
|
|
110
|
+
slot_ids = @lock.synchronize do
|
|
111
|
+
next nil unless @live.key?(worker_index) && !@retiring.key?(worker_index)
|
|
112
|
+
|
|
113
|
+
@retiring[worker_index] = true
|
|
114
|
+
@worker_slots[worker_index]
|
|
115
|
+
end
|
|
116
|
+
return false unless slot_ids
|
|
117
|
+
|
|
118
|
+
slot_ids.each { |id| Native.retire_slot(@server_id, id) }
|
|
119
|
+
true
|
|
120
|
+
end
|
|
121
|
+
|
|
55
122
|
# Replace the ractor owning slot `worker_id`: spawn a fresh supervised
|
|
56
123
|
# ractor, then quarantine the old ractor's slots. The old supervisor
|
|
57
124
|
# thread stays blocked in ractor.value on the wedged ractor (it and the
|
|
@@ -90,6 +157,9 @@ module Kino
|
|
|
90
157
|
private
|
|
91
158
|
|
|
92
159
|
def supervise(index)
|
|
160
|
+
# Live from the moment it is asked for, not from when its thread gets
|
|
161
|
+
# around to spawning: `grow` reports the count right after this.
|
|
162
|
+
@lock.synchronize { @live[index] = true }
|
|
93
163
|
Thread.new do
|
|
94
164
|
Thread.current.name = "supervisor-#{index}"
|
|
95
165
|
crashes = 0
|
|
@@ -97,8 +167,9 @@ module Kino
|
|
|
97
167
|
ractor, worker_ids = spawn_worker(index)
|
|
98
168
|
begin
|
|
99
169
|
ractor.value # blocks until the ractor terminates
|
|
100
|
-
HookFire.fire(@on_worker_exit, "on_worker_exit", index, nil) # clean exit:
|
|
101
|
-
|
|
170
|
+
HookFire.fire(@on_worker_exit, "on_worker_exit", index, nil) # clean exit: drained or retired
|
|
171
|
+
exited(index, worker_ids)
|
|
172
|
+
break
|
|
102
173
|
rescue Ractor::Error => e
|
|
103
174
|
# The ractor died mid-flight. Anything it was serving will never
|
|
104
175
|
# be answered by Ruby: 500 those clients NOW (not when GC gets
|
|
@@ -106,11 +177,17 @@ module Kino
|
|
|
106
177
|
worker_ids.each { |id| Native.abort_inflight(@server_id, id) }
|
|
107
178
|
cause = (e.respond_to?(:cause) && e.cause) ? e.cause : e
|
|
108
179
|
HookFire.fire(@on_worker_exit, "on_worker_exit", index, cause)
|
|
109
|
-
|
|
180
|
+
if draining?
|
|
181
|
+
exited(index, nil)
|
|
182
|
+
break
|
|
183
|
+
end
|
|
110
184
|
|
|
111
185
|
crashes += 1
|
|
112
186
|
Native.record_respawn(@server_id)
|
|
113
187
|
Log.error("worker-#{index} crashed (#{cause.class}: #{cause.message}); respawning")
|
|
188
|
+
# A crashed worker that was on its way out respawns on fresh
|
|
189
|
+
# slots like any other; the scaler retires it again when idle.
|
|
190
|
+
@lock.synchronize { @retiring.delete(index) }
|
|
114
191
|
# Policy (crash recovery): unlimited respawn
|
|
115
192
|
# keeps the server up under rare crashes but turns a
|
|
116
193
|
# crash-on-every-request bug into a busy loop. A circuit breaker
|
|
@@ -121,11 +198,10 @@ module Kino
|
|
|
121
198
|
end
|
|
122
199
|
end
|
|
123
200
|
|
|
124
|
-
# Fresh ractor
|
|
125
|
-
#
|
|
126
|
-
# old slot.
|
|
201
|
+
# Fresh ractor on slots from the bank: fresh ones, or ones a retired
|
|
202
|
+
# worker handed back.
|
|
127
203
|
def spawn_worker(worker_index)
|
|
128
|
-
worker_ids = Array.new(@threads) {
|
|
204
|
+
worker_ids = Array.new(@threads) { @bank.claim }
|
|
129
205
|
@lock.synchronize do
|
|
130
206
|
@worker_slots[worker_index] = worker_ids
|
|
131
207
|
worker_ids.each { |id| @slot_to_worker[id] = worker_index }
|
|
@@ -147,6 +223,24 @@ module Kino
|
|
|
147
223
|
[ractor, worker_ids]
|
|
148
224
|
end
|
|
149
225
|
|
|
226
|
+
# Bookkeeping for a supervisor thread that is done: the worker is no
|
|
227
|
+
# longer live, a retired worker's slots go back to the bank, and the
|
|
228
|
+
# thread leaves the join set so a long-lived elastic pool does not
|
|
229
|
+
# accumulate dead threads.
|
|
230
|
+
def exited(index, worker_ids)
|
|
231
|
+
retired = @lock.synchronize do
|
|
232
|
+
@live.delete(index)
|
|
233
|
+
@supervisor_threads.delete(Thread.current)
|
|
234
|
+
@retiring.delete(index)
|
|
235
|
+
end
|
|
236
|
+
@bank.release(worker_ids) if retired && worker_ids
|
|
237
|
+
report_active
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def report_active
|
|
241
|
+
Native.set_active_workers(@server_id, active_count)
|
|
242
|
+
end
|
|
243
|
+
|
|
150
244
|
def draining?
|
|
151
245
|
@lock.synchronize { @draining }
|
|
152
246
|
end
|
data/lib/kino/server.rb
CHANGED
|
@@ -68,6 +68,16 @@ module Kino
|
|
|
68
68
|
@bind = settings[:bind]
|
|
69
69
|
@requested_port = settings[:port]
|
|
70
70
|
@workers = Integer(settings[:workers])
|
|
71
|
+
# The pool ceiling; equal to the floor for a fixed pool.
|
|
72
|
+
@max_workers = settings[:max_workers].nil? ? @workers : Integer(settings[:max_workers])
|
|
73
|
+
if @max_workers < @workers
|
|
74
|
+
raise ArgumentError, "max_workers (#{@max_workers}) must be at least workers (#{@workers})"
|
|
75
|
+
end
|
|
76
|
+
@scale_down_after = settings[:scale_down_after].nil? ? 30.0 : Float(settings[:scale_down_after])
|
|
77
|
+
raise ArgumentError, "scale_down_after must be positive" unless @scale_down_after.positive?
|
|
78
|
+
if !settings[:scale_down_after].nil? && !elastic?
|
|
79
|
+
Log.warn("scale_down_after has no effect unless max_workers is above workers")
|
|
80
|
+
end
|
|
71
81
|
@on_error = validate_hook(settings[:on_error], :on_error)
|
|
72
82
|
@after_worker_boot = validate_hook(settings[:after_worker_boot], :after_worker_boot)
|
|
73
83
|
@after_request_complete = validate_hook(settings[:after_request_complete], :after_request_complete)
|
|
@@ -81,8 +91,9 @@ module Kino
|
|
|
81
91
|
# The access log's GC and allocation figures come from the VM's
|
|
82
92
|
# process-wide counters, so they are measured only where one
|
|
83
93
|
# request at a time can own them: the GVL serializes :threaded
|
|
84
|
-
# mode, and a single ractor
|
|
85
|
-
|
|
94
|
+
# mode, and a single ractor (a pool that can never grow past one)
|
|
95
|
+
# has nothing to race.
|
|
96
|
+
access_timing: !!settings[:log_requests] && (@mode == :threaded || @max_workers == 1)
|
|
86
97
|
)
|
|
87
98
|
# Default threads per mode: 1 in :ractor (threads inside a ractor
|
|
88
99
|
# share its lock; a measured +17% on fast handlers; raise `workers`
|
|
@@ -109,6 +120,7 @@ module Kino
|
|
|
109
120
|
if @tls && unix?
|
|
110
121
|
raise ArgumentError, "TLS is not supported on a unix socket bind; terminate TLS at the proxy in front"
|
|
111
122
|
end
|
|
123
|
+
@http2 = settings.fetch(:http2, true) ? true : false
|
|
112
124
|
@pidfile = settings[:pidfile]
|
|
113
125
|
@control_bind = settings[:control_bind]&.to_s
|
|
114
126
|
@control_token = settings[:control_token]&.to_s
|
|
@@ -125,10 +137,10 @@ module Kino
|
|
|
125
137
|
else
|
|
126
138
|
@workers * @threads
|
|
127
139
|
end
|
|
128
|
-
@worker_threads = []
|
|
129
|
-
@worker_threads_lock = Mutex.new
|
|
130
140
|
@supervisor = nil
|
|
141
|
+
@threaded_pool = nil
|
|
131
142
|
@quarantine_monitor = nil
|
|
143
|
+
@pool_scaler = nil
|
|
132
144
|
@started = false
|
|
133
145
|
end
|
|
134
146
|
|
|
@@ -155,8 +167,10 @@ module Kino
|
|
|
155
167
|
io_threads: @io_threads,
|
|
156
168
|
tokio_threads: @tokio_threads,
|
|
157
169
|
tls_cert: @tls&.fetch(:cert), tls_key: @tls&.fetch(:key),
|
|
170
|
+
http2: @http2,
|
|
158
171
|
lanes: @lanes, log_requests: @log_requests,
|
|
159
|
-
mode: @mode.to_s, workers: @workers,
|
|
172
|
+
mode: @mode.to_s, workers: @workers, max_workers: @max_workers,
|
|
173
|
+
threads: @threads, batch: @batch,
|
|
160
174
|
control_bind: @control_bind, control_token: @control_token
|
|
161
175
|
)
|
|
162
176
|
booted = true
|
|
@@ -167,12 +181,15 @@ module Kino
|
|
|
167
181
|
# lifetime so in-flight buffers survive even a worker ractor crash.
|
|
168
182
|
@pin_keeper = Native.pin_keeper(@id)
|
|
169
183
|
if @mode == :ractor
|
|
184
|
+
warn_scheduler_cap
|
|
170
185
|
@supervisor = RactorSupervisor.new(@id, @app, workers: @workers, threads: @threads,
|
|
171
186
|
batch: @batch, hooks: @worker_hooks, on_worker_exit: @on_worker_exit).start
|
|
172
187
|
else
|
|
173
|
-
@
|
|
188
|
+
@threaded_pool = ThreadedPool.new(@id, @app, threads: @threads, batch: @batch,
|
|
189
|
+
hooks: @worker_hooks, on_worker_exit: @on_worker_exit).start(@workers)
|
|
174
190
|
end
|
|
175
191
|
start_quarantine_monitor if @quarantine_timeout_ms
|
|
192
|
+
start_pool_scaler if elastic?
|
|
176
193
|
Native.control_ready(@id)
|
|
177
194
|
HookFire.fire(@after_boot, "after_boot")
|
|
178
195
|
@started = true
|
|
@@ -190,6 +207,7 @@ module Kino
|
|
|
190
207
|
def shutdown(timeout: nil)
|
|
191
208
|
return unless @started
|
|
192
209
|
|
|
210
|
+
@pool_scaler&.stop
|
|
193
211
|
@quarantine_monitor&.stop
|
|
194
212
|
deadline = monotonic_now + (timeout || @shutdown_timeout)
|
|
195
213
|
Native.stop_accepting(@id)
|
|
@@ -222,7 +240,6 @@ module Kino
|
|
|
222
240
|
# The runtime is gone, so hyper has dropped every pinned buffer;
|
|
223
241
|
# the keeper (and the strings it marked) may now be collected.
|
|
224
242
|
@pin_keeper = nil
|
|
225
|
-
@worker_threads.clear
|
|
226
243
|
@started = false
|
|
227
244
|
remove_pidfile if @pidfile
|
|
228
245
|
nil
|
|
@@ -231,7 +248,7 @@ module Kino
|
|
|
231
248
|
# Block until every worker has exited (i.e. until shutdown).
|
|
232
249
|
# @return [void]
|
|
233
250
|
def wait
|
|
234
|
-
|
|
251
|
+
pool.join
|
|
235
252
|
end
|
|
236
253
|
|
|
237
254
|
# Production entry point: build the server and {#run} it. The `kino`
|
|
@@ -296,19 +313,22 @@ module Kino
|
|
|
296
313
|
# lanes mode) once started
|
|
297
314
|
def stats
|
|
298
315
|
base = {
|
|
299
|
-
mode: @mode, lanes: @lanes, workers: @workers,
|
|
300
|
-
batch: @batch, respawns: 0
|
|
316
|
+
mode: @mode, lanes: @lanes, workers: @workers, max_workers: @max_workers,
|
|
317
|
+
threads: @threads, batch: @batch, respawns: 0,
|
|
318
|
+
active_workers: @workers, scale_ups: 0, scale_downs: 0
|
|
301
319
|
}
|
|
302
320
|
return base unless @started
|
|
303
321
|
|
|
304
322
|
queued, in_flight, served, rejected, timeouts, respawns, lane_depths = Native.server_stats(@id)
|
|
305
323
|
base.merge!(queued:, in_flight:, served:, rejected:, timeouts:, respawns:)
|
|
306
324
|
base[:lane_depths] = lane_depths if lane_depths
|
|
325
|
+
active_workers, _max_workers, scale_ups, scale_downs = Native.pool_stats(@id)
|
|
326
|
+
base.merge!(active_workers:, scale_ups:, scale_downs:)
|
|
307
327
|
rows = Native.worker_stats(@id)
|
|
308
|
-
base[:worker_status] = rows.map do |index, served, in_flight, busy_ms, quarantined|
|
|
309
|
-
{index:, served:, in_flight:, busy_ms:, quarantined:}
|
|
328
|
+
base[:worker_status] = rows.map do |index, served, in_flight, busy_ms, quarantined, retired|
|
|
329
|
+
{index:, served:, in_flight:, busy_ms:, quarantined:, retired:}
|
|
310
330
|
end
|
|
311
|
-
base[:quarantined] = rows.count { |
|
|
331
|
+
base[:quarantined] = rows.count { |row| row[4] }
|
|
312
332
|
count, sum_seconds = Native.queue_time(@id)
|
|
313
333
|
base[:queue_time] = {count:, sum_seconds:}
|
|
314
334
|
base
|
|
@@ -316,63 +336,38 @@ module Kino
|
|
|
316
336
|
|
|
317
337
|
private
|
|
318
338
|
|
|
319
|
-
#
|
|
320
|
-
#
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
# Named so log lines from inside say which worker spoke.
|
|
325
|
-
Thread.current.name = "worker-#{worker_id}"
|
|
326
|
-
error = nil
|
|
327
|
-
begin
|
|
328
|
-
Worker.run(@id, worker_id, @app, @batch, @worker_hooks)
|
|
329
|
-
rescue Exception => e # rubocop:disable Lint/RescueException -- a hard crash in a threaded worker thread
|
|
330
|
-
error = e
|
|
331
|
-
raise
|
|
332
|
-
ensure
|
|
333
|
-
HookFire.fire(@on_worker_exit, "on_worker_exit", worker_id, error)
|
|
334
|
-
end
|
|
335
|
-
end
|
|
339
|
+
# The worker pool this mode runs: the ractor supervisor, or the
|
|
340
|
+
# threaded pool. Both spawn, retire, replace and join workers behind
|
|
341
|
+
# the same methods.
|
|
342
|
+
def pool
|
|
343
|
+
@supervisor || @threaded_pool
|
|
336
344
|
end
|
|
337
345
|
|
|
338
|
-
#
|
|
339
|
-
#
|
|
340
|
-
def
|
|
341
|
-
@
|
|
346
|
+
# Both pools are quarantine replacers: replace(worker_id) spawns a
|
|
347
|
+
# replacement worker, then quarantines the wedged slot.
|
|
348
|
+
def start_quarantine_monitor
|
|
349
|
+
@quarantine_monitor = QuarantineMonitor.new(
|
|
350
|
+
server_id: @id, timeout_ms: @quarantine_timeout_ms,
|
|
351
|
+
max: @quarantine_max, replacer: pool
|
|
352
|
+
).start
|
|
342
353
|
end
|
|
343
354
|
|
|
344
|
-
#
|
|
345
|
-
#
|
|
346
|
-
#
|
|
347
|
-
#
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
def initialize(server_id:, spawner:, tracker:)
|
|
352
|
-
@server_id = server_id
|
|
353
|
-
@spawner = spawner
|
|
354
|
-
@tracker = tracker
|
|
355
|
-
end
|
|
355
|
+
# Ruby's M:N scheduler runs non-main ractors' Ruby code on at most
|
|
356
|
+
# RUBY_MAX_CPU native threads (default 8). Workers past that cap share
|
|
357
|
+
# timeslices instead of adding parallelism, and a fresh ractor can wait
|
|
358
|
+
# seconds for its first one while the others are CPU-bound.
|
|
359
|
+
def warn_scheduler_cap
|
|
360
|
+
cap = Integer(ENV.fetch("RUBY_MAX_CPU", "8"), exception: false) || 8
|
|
361
|
+
return if @max_workers <= cap
|
|
356
362
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
Native.quarantine_slot(@server_id, worker_id) # quarantine after success
|
|
360
|
-
@tracker.call(thread)
|
|
361
|
-
true
|
|
362
|
-
end
|
|
363
|
+
Log.warn("#{@max_workers} ractor workers exceed RUBY_MAX_CPU=#{cap}: only #{cap} can run " \
|
|
364
|
+
"Ruby code at once; set RUBY_MAX_CPU=#{@max_workers} for CPU-bound apps")
|
|
363
365
|
end
|
|
364
|
-
private_constant :ThreadedReplacer
|
|
365
366
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
def start_quarantine_monitor
|
|
371
|
-
replacer = @supervisor || ThreadedReplacer.new(server_id: @id, spawner: method(:spawn_worker_thread),
|
|
372
|
-
tracker: method(:track_replacement_thread))
|
|
373
|
-
@quarantine_monitor = QuarantineMonitor.new(
|
|
374
|
-
server_id: @id, timeout_ms: @quarantine_timeout_ms,
|
|
375
|
-
max: @quarantine_max, replacer: replacer
|
|
367
|
+
def start_pool_scaler
|
|
368
|
+
@pool_scaler = PoolScaler.new(
|
|
369
|
+
server_id: @id, pool: pool, floor: @workers, ceiling: @max_workers,
|
|
370
|
+
scale_down_after: @scale_down_after
|
|
376
371
|
).start
|
|
377
372
|
end
|
|
378
373
|
|
|
@@ -398,6 +393,11 @@ module Kino
|
|
|
398
393
|
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
399
394
|
end
|
|
400
395
|
|
|
396
|
+
# A pool that can grow: the ceiling is above the floor.
|
|
397
|
+
def elastic?
|
|
398
|
+
@max_workers > @workers
|
|
399
|
+
end
|
|
400
|
+
|
|
401
401
|
# Default connection cap: most of the process open-file limit. A
|
|
402
402
|
# connection flood's failure mode is descriptor exhaustion, and in
|
|
403
403
|
# :ractor/:threaded mode the app's own sockets and files share this
|
|
@@ -472,34 +472,15 @@ module Kino
|
|
|
472
472
|
end
|
|
473
473
|
|
|
474
474
|
def join_workers(deadline)
|
|
475
|
-
|
|
476
|
-
@supervisor.shutdown([deadline - monotonic_now, 0].max)
|
|
477
|
-
else
|
|
478
|
-
threads = @worker_threads_lock.synchronize { @worker_threads.dup }
|
|
479
|
-
threads.each do |thread|
|
|
480
|
-
thread.join([deadline - monotonic_now, 0.01].max)
|
|
481
|
-
end
|
|
482
|
-
end
|
|
475
|
+
pool.shutdown([deadline - monotonic_now, 0].max)
|
|
483
476
|
end
|
|
484
477
|
|
|
485
478
|
def workers_done?
|
|
486
|
-
|
|
487
|
-
@supervisor.done?
|
|
488
|
-
else
|
|
489
|
-
threads = @worker_threads_lock.synchronize { @worker_threads.dup }
|
|
490
|
-
threads.none?(&:alive?)
|
|
491
|
-
end
|
|
479
|
+
pool.done?
|
|
492
480
|
end
|
|
493
481
|
|
|
494
482
|
def kill_stragglers
|
|
495
|
-
|
|
496
|
-
# Ractors cannot be force-killed; their clients were already freed
|
|
497
|
-
# by abort_all_inflight. The stuck ractor leaks until process exit.
|
|
498
|
-
Log.error("shutdown deadline passed with stuck ractor workers") unless @supervisor.done?
|
|
499
|
-
else
|
|
500
|
-
threads = @worker_threads_lock.synchronize { @worker_threads.dup }
|
|
501
|
-
threads.each { |thread| thread.kill if thread.alive? }
|
|
502
|
-
end
|
|
483
|
+
pool.kill_stragglers
|
|
503
484
|
end
|
|
504
485
|
|
|
505
486
|
# Policy (mode resolution): when is an app safe for ractor
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kino
|
|
4
|
+
# @private
|
|
5
|
+
# Dispatch slots for a worker pool: fresh ones from the native registry,
|
|
6
|
+
# or ones that retired workers handed back, reset for their next
|
|
7
|
+
# occupant. The native side never removes a slot, so recycling is what
|
|
8
|
+
# keeps the slot table from growing as an elastic pool breathes. Only
|
|
9
|
+
# cleanly exited workers return slots; a crashed worker's slots are
|
|
10
|
+
# abandoned (stale interrupt kicks and dead weak refs go down with
|
|
11
|
+
# them).
|
|
12
|
+
class SlotBank
|
|
13
|
+
def initialize(server_id)
|
|
14
|
+
@server_id = server_id
|
|
15
|
+
@free = []
|
|
16
|
+
@lock = Mutex.new
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def claim
|
|
20
|
+
id = @lock.synchronize { @free.pop }
|
|
21
|
+
return Native.register_worker(@server_id) unless id
|
|
22
|
+
|
|
23
|
+
Native.reset_slot(@server_id, id)
|
|
24
|
+
id
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def release(ids)
|
|
28
|
+
@lock.synchronize { @free.concat(ids) }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -20,6 +20,11 @@
|
|
|
20
20
|
# PEM strings also work).
|
|
21
21
|
# tls cert: "config/certs/server.pem", key: "config/certs/server.key"
|
|
22
22
|
|
|
23
|
+
# HTTP/2 is on by default: negotiated via ALPN on TLS binds, and served
|
|
24
|
+
# to prior-knowledge (h2c) clients on plaintext. Everything else keeps
|
|
25
|
+
# getting HTTP/1.1. Set false to serve HTTP/1 only.
|
|
26
|
+
# http2 false
|
|
27
|
+
|
|
23
28
|
## Topology
|
|
24
29
|
|
|
25
30
|
# How many workers to run. Each worker handles requests independently;
|
|
@@ -35,6 +40,21 @@
|
|
|
35
40
|
# `workers` instead.
|
|
36
41
|
# threads 1
|
|
37
42
|
|
|
43
|
+
## Elastic pool (experimental)
|
|
44
|
+
#
|
|
45
|
+
# Let the pool grow past `workers` under load and shrink back when
|
|
46
|
+
# idle: one worker is added every 100 ms while requests wait in the
|
|
47
|
+
# queue, and a worker above `workers` retires after `scale_down_after`
|
|
48
|
+
# seconds idle. Helps apps that wait on databases or other services;
|
|
49
|
+
# pure CPU work gains nothing past the core count. Unset (the default)
|
|
50
|
+
# keeps the pool fixed. In :ractor mode, Ruby runs at most RUBY_MAX_CPU
|
|
51
|
+
# (default 8) ractors' Ruby code at once; set that variable to match
|
|
52
|
+
# `max_workers` on bigger boxes.
|
|
53
|
+
# max_workers 32
|
|
54
|
+
|
|
55
|
+
# Seconds an extra worker must sit idle before it is retired.
|
|
56
|
+
# scale_down_after 30
|
|
57
|
+
|
|
38
58
|
## Dispatch mode
|
|
39
59
|
#
|
|
40
60
|
# :auto - picks :ractor when your app supports it, else :threaded.
|