kino 0.6.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 +21 -0
- data/Cargo.lock +1 -1
- data/README.md +52 -0
- data/doc/architecture.md +10 -0
- data/ext/kino/Cargo.toml +1 -1
- data/ext/kino/src/control.rs +103 -6
- data/ext/kino/src/env_strings.rs +249 -120
- data/ext/kino/src/lib.rs +11 -0
- data/ext/kino/src/pin.rs +21 -9
- data/ext/kino/src/queue.rs +99 -3
- data/ext/kino/src/registry.rs +141 -0
- data/ext/kino/src/request.rs +1 -1
- data/ext/kino/src/server.rs +218 -3
- data/ext/kino/src/test_support.rs +44 -0
- data/lib/kino/cli.rb +12 -4
- data/lib/kino/configuration.rb +16 -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 +64 -85
- data/lib/kino/slot_bank.rb +31 -0
- data/lib/kino/templates/kino.rb.tt +15 -0
- data/lib/kino/threaded_pool.rb +186 -0
- data/lib/kino/version.rb +1 -1
- data/lib/kino.rb +4 -0
- data/lib/rackup/handler/kino.rb +4 -4
- data/sig/kino.rbs +2 -0
- metadata +5 -1
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`
|
|
@@ -126,10 +137,10 @@ module Kino
|
|
|
126
137
|
else
|
|
127
138
|
@workers * @threads
|
|
128
139
|
end
|
|
129
|
-
@worker_threads = []
|
|
130
|
-
@worker_threads_lock = Mutex.new
|
|
131
140
|
@supervisor = nil
|
|
141
|
+
@threaded_pool = nil
|
|
132
142
|
@quarantine_monitor = nil
|
|
143
|
+
@pool_scaler = nil
|
|
133
144
|
@started = false
|
|
134
145
|
end
|
|
135
146
|
|
|
@@ -158,7 +169,8 @@ module Kino
|
|
|
158
169
|
tls_cert: @tls&.fetch(:cert), tls_key: @tls&.fetch(:key),
|
|
159
170
|
http2: @http2,
|
|
160
171
|
lanes: @lanes, log_requests: @log_requests,
|
|
161
|
-
mode: @mode.to_s, workers: @workers,
|
|
172
|
+
mode: @mode.to_s, workers: @workers, max_workers: @max_workers,
|
|
173
|
+
threads: @threads, batch: @batch,
|
|
162
174
|
control_bind: @control_bind, control_token: @control_token
|
|
163
175
|
)
|
|
164
176
|
booted = true
|
|
@@ -169,12 +181,15 @@ module Kino
|
|
|
169
181
|
# lifetime so in-flight buffers survive even a worker ractor crash.
|
|
170
182
|
@pin_keeper = Native.pin_keeper(@id)
|
|
171
183
|
if @mode == :ractor
|
|
184
|
+
warn_scheduler_cap
|
|
172
185
|
@supervisor = RactorSupervisor.new(@id, @app, workers: @workers, threads: @threads,
|
|
173
186
|
batch: @batch, hooks: @worker_hooks, on_worker_exit: @on_worker_exit).start
|
|
174
187
|
else
|
|
175
|
-
@
|
|
188
|
+
@threaded_pool = ThreadedPool.new(@id, @app, threads: @threads, batch: @batch,
|
|
189
|
+
hooks: @worker_hooks, on_worker_exit: @on_worker_exit).start(@workers)
|
|
176
190
|
end
|
|
177
191
|
start_quarantine_monitor if @quarantine_timeout_ms
|
|
192
|
+
start_pool_scaler if elastic?
|
|
178
193
|
Native.control_ready(@id)
|
|
179
194
|
HookFire.fire(@after_boot, "after_boot")
|
|
180
195
|
@started = true
|
|
@@ -192,6 +207,7 @@ module Kino
|
|
|
192
207
|
def shutdown(timeout: nil)
|
|
193
208
|
return unless @started
|
|
194
209
|
|
|
210
|
+
@pool_scaler&.stop
|
|
195
211
|
@quarantine_monitor&.stop
|
|
196
212
|
deadline = monotonic_now + (timeout || @shutdown_timeout)
|
|
197
213
|
Native.stop_accepting(@id)
|
|
@@ -224,7 +240,6 @@ module Kino
|
|
|
224
240
|
# The runtime is gone, so hyper has dropped every pinned buffer;
|
|
225
241
|
# the keeper (and the strings it marked) may now be collected.
|
|
226
242
|
@pin_keeper = nil
|
|
227
|
-
@worker_threads.clear
|
|
228
243
|
@started = false
|
|
229
244
|
remove_pidfile if @pidfile
|
|
230
245
|
nil
|
|
@@ -233,7 +248,7 @@ module Kino
|
|
|
233
248
|
# Block until every worker has exited (i.e. until shutdown).
|
|
234
249
|
# @return [void]
|
|
235
250
|
def wait
|
|
236
|
-
|
|
251
|
+
pool.join
|
|
237
252
|
end
|
|
238
253
|
|
|
239
254
|
# Production entry point: build the server and {#run} it. The `kino`
|
|
@@ -298,19 +313,22 @@ module Kino
|
|
|
298
313
|
# lanes mode) once started
|
|
299
314
|
def stats
|
|
300
315
|
base = {
|
|
301
|
-
mode: @mode, lanes: @lanes, workers: @workers,
|
|
302
|
-
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
|
|
303
319
|
}
|
|
304
320
|
return base unless @started
|
|
305
321
|
|
|
306
322
|
queued, in_flight, served, rejected, timeouts, respawns, lane_depths = Native.server_stats(@id)
|
|
307
323
|
base.merge!(queued:, in_flight:, served:, rejected:, timeouts:, respawns:)
|
|
308
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:)
|
|
309
327
|
rows = Native.worker_stats(@id)
|
|
310
|
-
base[:worker_status] = rows.map do |index, served, in_flight, busy_ms, quarantined|
|
|
311
|
-
{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:}
|
|
312
330
|
end
|
|
313
|
-
base[:quarantined] = rows.count { |
|
|
331
|
+
base[:quarantined] = rows.count { |row| row[4] }
|
|
314
332
|
count, sum_seconds = Native.queue_time(@id)
|
|
315
333
|
base[:queue_time] = {count:, sum_seconds:}
|
|
316
334
|
base
|
|
@@ -318,63 +336,38 @@ module Kino
|
|
|
318
336
|
|
|
319
337
|
private
|
|
320
338
|
|
|
321
|
-
#
|
|
322
|
-
#
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
# Named so log lines from inside say which worker spoke.
|
|
327
|
-
Thread.current.name = "worker-#{worker_id}"
|
|
328
|
-
error = nil
|
|
329
|
-
begin
|
|
330
|
-
Worker.run(@id, worker_id, @app, @batch, @worker_hooks)
|
|
331
|
-
rescue Exception => e # rubocop:disable Lint/RescueException -- a hard crash in a threaded worker thread
|
|
332
|
-
error = e
|
|
333
|
-
raise
|
|
334
|
-
ensure
|
|
335
|
-
HookFire.fire(@on_worker_exit, "on_worker_exit", worker_id, error)
|
|
336
|
-
end
|
|
337
|
-
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
|
|
338
344
|
end
|
|
339
345
|
|
|
340
|
-
#
|
|
341
|
-
#
|
|
342
|
-
def
|
|
343
|
-
@
|
|
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
|
|
344
353
|
end
|
|
345
354
|
|
|
346
|
-
#
|
|
347
|
-
#
|
|
348
|
-
#
|
|
349
|
-
#
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
def initialize(server_id:, spawner:, tracker:)
|
|
354
|
-
@server_id = server_id
|
|
355
|
-
@spawner = spawner
|
|
356
|
-
@tracker = tracker
|
|
357
|
-
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
|
|
358
362
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
Native.quarantine_slot(@server_id, worker_id) # quarantine after success
|
|
362
|
-
@tracker.call(thread)
|
|
363
|
-
true
|
|
364
|
-
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")
|
|
365
365
|
end
|
|
366
|
-
private_constant :ThreadedReplacer
|
|
367
366
|
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
def start_quarantine_monitor
|
|
373
|
-
replacer = @supervisor || ThreadedReplacer.new(server_id: @id, spawner: method(:spawn_worker_thread),
|
|
374
|
-
tracker: method(:track_replacement_thread))
|
|
375
|
-
@quarantine_monitor = QuarantineMonitor.new(
|
|
376
|
-
server_id: @id, timeout_ms: @quarantine_timeout_ms,
|
|
377
|
-
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
|
|
378
371
|
).start
|
|
379
372
|
end
|
|
380
373
|
|
|
@@ -400,6 +393,11 @@ module Kino
|
|
|
400
393
|
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
401
394
|
end
|
|
402
395
|
|
|
396
|
+
# A pool that can grow: the ceiling is above the floor.
|
|
397
|
+
def elastic?
|
|
398
|
+
@max_workers > @workers
|
|
399
|
+
end
|
|
400
|
+
|
|
403
401
|
# Default connection cap: most of the process open-file limit. A
|
|
404
402
|
# connection flood's failure mode is descriptor exhaustion, and in
|
|
405
403
|
# :ractor/:threaded mode the app's own sockets and files share this
|
|
@@ -474,34 +472,15 @@ module Kino
|
|
|
474
472
|
end
|
|
475
473
|
|
|
476
474
|
def join_workers(deadline)
|
|
477
|
-
|
|
478
|
-
@supervisor.shutdown([deadline - monotonic_now, 0].max)
|
|
479
|
-
else
|
|
480
|
-
threads = @worker_threads_lock.synchronize { @worker_threads.dup }
|
|
481
|
-
threads.each do |thread|
|
|
482
|
-
thread.join([deadline - monotonic_now, 0.01].max)
|
|
483
|
-
end
|
|
484
|
-
end
|
|
475
|
+
pool.shutdown([deadline - monotonic_now, 0].max)
|
|
485
476
|
end
|
|
486
477
|
|
|
487
478
|
def workers_done?
|
|
488
|
-
|
|
489
|
-
@supervisor.done?
|
|
490
|
-
else
|
|
491
|
-
threads = @worker_threads_lock.synchronize { @worker_threads.dup }
|
|
492
|
-
threads.none?(&:alive?)
|
|
493
|
-
end
|
|
479
|
+
pool.done?
|
|
494
480
|
end
|
|
495
481
|
|
|
496
482
|
def kill_stragglers
|
|
497
|
-
|
|
498
|
-
# Ractors cannot be force-killed; their clients were already freed
|
|
499
|
-
# by abort_all_inflight. The stuck ractor leaks until process exit.
|
|
500
|
-
Log.error("shutdown deadline passed with stuck ractor workers") unless @supervisor.done?
|
|
501
|
-
else
|
|
502
|
-
threads = @worker_threads_lock.synchronize { @worker_threads.dup }
|
|
503
|
-
threads.each { |thread| thread.kill if thread.alive? }
|
|
504
|
-
end
|
|
483
|
+
pool.kill_stragglers
|
|
505
484
|
end
|
|
506
485
|
|
|
507
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
|
|
@@ -40,6 +40,21 @@
|
|
|
40
40
|
# `workers` instead.
|
|
41
41
|
# threads 1
|
|
42
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
|
+
|
|
43
58
|
## Dispatch mode
|
|
44
59
|
#
|
|
45
60
|
# :auto - picks :ractor when your app supports it, else :threaded.
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kino
|
|
4
|
+
# @private
|
|
5
|
+
# The :threaded-mode worker pool: `workers` groups of `threads` plain
|
|
6
|
+
# Threads, each thread on its own dispatch slot. A group is one ractor's
|
|
7
|
+
# worth of capacity, so `workers` and `max_workers` mean the same thing
|
|
8
|
+
# in both modes. Behind PoolScaler here (`grow`, `retire`, `groups`,
|
|
9
|
+
# `active_count`), the quarantine monitor's replacer (`replace`), and
|
|
10
|
+
# the join/kill sweeps Server#shutdown runs.
|
|
11
|
+
class ThreadedPool
|
|
12
|
+
def initialize(server_id, app, threads:, batch: 1, hooks: nil, on_worker_exit: nil)
|
|
13
|
+
@server_id = server_id
|
|
14
|
+
@app = app
|
|
15
|
+
@threads = threads
|
|
16
|
+
@batch = batch
|
|
17
|
+
@hooks = hooks
|
|
18
|
+
@on_worker_exit = on_worker_exit
|
|
19
|
+
@lock = Mutex.new
|
|
20
|
+
# index => {slots:, threads:}; the groups asked to leave; quarantine
|
|
21
|
+
# replacements (one thread each, standing in for a wedged slot:
|
|
22
|
+
# neither counted nor retired, so the wedged group keeps counting as
|
|
23
|
+
# the capacity it still is). Retired groups hand their slots back to
|
|
24
|
+
# the bank.
|
|
25
|
+
@groups = {}
|
|
26
|
+
@slot_to_group = {}
|
|
27
|
+
@retiring = {}
|
|
28
|
+
@bank = SlotBank.new(server_id)
|
|
29
|
+
@replacements = {}
|
|
30
|
+
@wedged = {}
|
|
31
|
+
@next_index = -1
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def start(workers)
|
|
35
|
+
workers.times { spawn_group }
|
|
36
|
+
report_active
|
|
37
|
+
self
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Groups that are staying: quarantine replacements aside, and minus
|
|
41
|
+
# those already told to leave. A retiring group leaves the count at
|
|
42
|
+
# once, not when its last thread exits, so the scaler never sees a
|
|
43
|
+
# stale surplus and retires past its floor.
|
|
44
|
+
def active_count
|
|
45
|
+
@lock.synchronize do
|
|
46
|
+
@groups.count { |index, _| !@replacements.key?(index) && !@retiring.key?(index) }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Group index => slot ids for every group the scaler may retire: not
|
|
51
|
+
# already leaving, not wedged, not a quarantine replacement.
|
|
52
|
+
def groups
|
|
53
|
+
reap
|
|
54
|
+
@lock.synchronize do
|
|
55
|
+
@groups
|
|
56
|
+
.reject { |index, _| @retiring.key?(index) || @wedged.key?(index) || @replacements.key?(index) }
|
|
57
|
+
.transform_values { |group| group[:slots].dup }
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Add one group; returns its index.
|
|
62
|
+
def grow
|
|
63
|
+
reap
|
|
64
|
+
index = spawn_group
|
|
65
|
+
report_active
|
|
66
|
+
index
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Send a group home: its slots stop receiving work now, each thread
|
|
70
|
+
# finishes what it holds and leaves at its next idle tick, and the
|
|
71
|
+
# slots come back to the free list once every thread has exited.
|
|
72
|
+
def retire(index)
|
|
73
|
+
slots = @lock.synchronize do
|
|
74
|
+
next nil unless @groups.key?(index) && !@retiring.key?(index)
|
|
75
|
+
|
|
76
|
+
@retiring[index] = true
|
|
77
|
+
@groups[index][:slots]
|
|
78
|
+
end
|
|
79
|
+
return false unless slots
|
|
80
|
+
|
|
81
|
+
slots.each { |id| Native.retire_slot(@server_id, id) }
|
|
82
|
+
true
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The quarantine replacer: spawn a replacement thread on a fresh slot
|
|
86
|
+
# FIRST (may raise ThreadError), then quarantine the wedged slot.
|
|
87
|
+
def replace(worker_id)
|
|
88
|
+
spawn_group(slots: 1, replacement: true)
|
|
89
|
+
Native.quarantine_slot(@server_id, worker_id)
|
|
90
|
+
@lock.synchronize do
|
|
91
|
+
wedged = @slot_to_group[worker_id]
|
|
92
|
+
@wedged[wedged] = true if wedged
|
|
93
|
+
end
|
|
94
|
+
true
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Join every thread up to the (numeric) deadline.
|
|
98
|
+
def shutdown(timeout)
|
|
99
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
100
|
+
all_threads.each do |thread|
|
|
101
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
102
|
+
thread.join([remaining, 0.01].max)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def done?
|
|
107
|
+
all_threads.none?(&:alive?)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Block until every thread exits on its own (drain elsewhere).
|
|
111
|
+
def join
|
|
112
|
+
all_threads.each(&:join)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def kill_stragglers
|
|
116
|
+
all_threads.each { |thread| thread.kill if thread.alive? }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
def all_threads
|
|
122
|
+
@lock.synchronize { @groups.values.flat_map { |group| group[:threads] } }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# The group is in the table before its first thread starts, so a
|
|
126
|
+
# ThreadError partway through leaves nothing untracked for shutdown.
|
|
127
|
+
def spawn_group(slots: @threads, replacement: false)
|
|
128
|
+
group = {slots: [], threads: []}
|
|
129
|
+
index = @lock.synchronize do
|
|
130
|
+
@next_index += 1
|
|
131
|
+
@groups[@next_index] = group
|
|
132
|
+
@replacements[@next_index] = true if replacement
|
|
133
|
+
@next_index
|
|
134
|
+
end
|
|
135
|
+
slots.times do
|
|
136
|
+
id = @bank.claim
|
|
137
|
+
@lock.synchronize do
|
|
138
|
+
group[:slots] << id
|
|
139
|
+
@slot_to_group[id] = index
|
|
140
|
+
end
|
|
141
|
+
thread = spawn_thread(id)
|
|
142
|
+
@lock.synchronize { group[:threads] << thread }
|
|
143
|
+
end
|
|
144
|
+
index
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def spawn_thread(worker_id)
|
|
148
|
+
Thread.new do
|
|
149
|
+
# Named so log lines from inside say which worker spoke.
|
|
150
|
+
Thread.current.name = "worker-#{worker_id}"
|
|
151
|
+
error = nil
|
|
152
|
+
begin
|
|
153
|
+
Worker.run(@server_id, worker_id, @app, @batch, @hooks)
|
|
154
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- a hard crash in a threaded worker thread
|
|
155
|
+
error = e
|
|
156
|
+
raise
|
|
157
|
+
ensure
|
|
158
|
+
HookFire.fire(@on_worker_exit, "on_worker_exit", worker_id, error)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Retiring groups whose threads have all exited give their slots back
|
|
164
|
+
# and leave the table. Runs before every pool decision, so the count
|
|
165
|
+
# the control plane sees never lags by more than a scaler tick.
|
|
166
|
+
def reap
|
|
167
|
+
freed = @lock.synchronize do
|
|
168
|
+
done = @retiring.keys.select { |index| @groups[index][:threads].none?(&:alive?) }
|
|
169
|
+
done.flat_map do |index|
|
|
170
|
+
group = @groups.delete(index)
|
|
171
|
+
@retiring.delete(index)
|
|
172
|
+
group[:slots].each { |id| @slot_to_group.delete(id) }
|
|
173
|
+
group[:slots]
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
return if freed.empty?
|
|
177
|
+
|
|
178
|
+
@bank.release(freed)
|
|
179
|
+
report_active
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def report_active
|
|
183
|
+
Native.set_active_workers(@server_id, active_count)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
data/lib/kino/version.rb
CHANGED
data/lib/kino.rb
CHANGED
|
@@ -57,8 +57,12 @@ require_relative "kino/configuration"
|
|
|
57
57
|
require_relative "kino/hook_fire"
|
|
58
58
|
require_relative "kino/worker_hooks"
|
|
59
59
|
require_relative "kino/worker"
|
|
60
|
+
require_relative "kino/slot_bank"
|
|
60
61
|
require_relative "kino/ractor_supervisor"
|
|
62
|
+
require_relative "kino/threaded_pool"
|
|
63
|
+
require_relative "kino/monitor"
|
|
61
64
|
require_relative "kino/quarantine_monitor"
|
|
65
|
+
require_relative "kino/pool_scaler"
|
|
62
66
|
require_relative "kino/server"
|
|
63
67
|
|
|
64
68
|
# Hand the frozen shareable singletons to the native layer: it sets them
|
data/lib/rackup/handler/kino.rb
CHANGED
|
@@ -9,13 +9,13 @@ module Rackup
|
|
|
9
9
|
module Kino
|
|
10
10
|
# Host option name => Kino setting plus the coercion it needs: rackup
|
|
11
11
|
# hands `-O NAME=VALUE` values (and its own -p) over as strings.
|
|
12
|
-
OPTION_MAP = {
|
|
12
|
+
OPTION_MAP = Ractor.make_shareable({
|
|
13
13
|
Host: [:bind, ->(value) { value.to_s }],
|
|
14
14
|
Port: [:port, ->(value) { Integer(value) }],
|
|
15
15
|
Workers: [:workers, ->(value) { Integer(value) }],
|
|
16
16
|
Threads: [:threads, ->(value) { Integer(value) }],
|
|
17
17
|
Mode: [:mode, ->(value) { value.to_sym }]
|
|
18
|
-
}
|
|
18
|
+
})
|
|
19
19
|
private_constant :OPTION_MAP
|
|
20
20
|
|
|
21
21
|
# Boot a server for `app` and block until it shuts down, the way the
|
|
@@ -27,7 +27,7 @@ module Rackup
|
|
|
27
27
|
# want a handle on it
|
|
28
28
|
# @return [::Kino::Server] the stopped server, after shutdown
|
|
29
29
|
def self.run(app, **options)
|
|
30
|
-
require "kino"
|
|
30
|
+
require "kino" # audition:disable runtime-require
|
|
31
31
|
server = ::Kino::Server.new(app, **server_options(options))
|
|
32
32
|
yield server if block_given?
|
|
33
33
|
server.run
|
|
@@ -58,7 +58,7 @@ module Rackup
|
|
|
58
58
|
# @param options [Hash{Symbol => Object}]
|
|
59
59
|
# @return [Hash{Symbol => Object}]
|
|
60
60
|
def self.server_options(options)
|
|
61
|
-
require "kino"
|
|
61
|
+
require "kino" # audition:disable runtime-require
|
|
62
62
|
options = options.dup
|
|
63
63
|
host_defaults = {}
|
|
64
64
|
if (typed = options.delete(:user_supplied_options))
|
data/sig/kino.rbs
CHANGED
|
@@ -110,6 +110,8 @@ module Kino
|
|
|
110
110
|
def bind: (String host) -> untyped
|
|
111
111
|
def port: (int port) -> untyped
|
|
112
112
|
def workers: (int count) -> untyped
|
|
113
|
+
def max_workers: (int count) -> untyped
|
|
114
|
+
def scale_down_after: (Numeric seconds) -> untyped
|
|
113
115
|
def threads: (int count) -> untyped
|
|
114
116
|
def mode: (Symbol | String mode) -> untyped
|
|
115
117
|
def queue_depth: (int depth) -> untyped
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: kino
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yaroslav Markin
|
|
@@ -219,12 +219,16 @@ files:
|
|
|
219
219
|
- lib/kino/input.rb
|
|
220
220
|
- lib/kino/log.rb
|
|
221
221
|
- lib/kino/logger.rb
|
|
222
|
+
- lib/kino/monitor.rb
|
|
222
223
|
- lib/kino/null_input.rb
|
|
224
|
+
- lib/kino/pool_scaler.rb
|
|
223
225
|
- lib/kino/quarantine_monitor.rb
|
|
224
226
|
- lib/kino/ractor_supervisor.rb
|
|
225
227
|
- lib/kino/server.rb
|
|
228
|
+
- lib/kino/slot_bank.rb
|
|
226
229
|
- lib/kino/stream.rb
|
|
227
230
|
- lib/kino/templates/kino.rb.tt
|
|
231
|
+
- lib/kino/threaded_pool.rb
|
|
228
232
|
- lib/kino/version.rb
|
|
229
233
|
- lib/kino/worker.rb
|
|
230
234
|
- lib/kino/worker_hooks.rb
|