kino 0.5.0-aarch64-linux → 0.7.0-aarch64-linux
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/README.md +153 -44
- data/doc/architecture.md +64 -3
- data/doc/benchmarks.md +120 -26
- data/lib/kino/cli.rb +12 -4
- data/lib/kino/configuration.rb +22 -2
- data/lib/kino/kino.so +0 -0
- 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 +20 -2
|
@@ -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/worker.rb
CHANGED
|
@@ -20,12 +20,16 @@ module Kino
|
|
|
20
20
|
|
|
21
21
|
def run(server_id, worker_id, app, batch_size = 1, hooks = nil)
|
|
22
22
|
fire_after_worker_boot(hooks, worker_id)
|
|
23
|
+
# One resolution of (server, worker) into a native handle; every take
|
|
24
|
+
# after this skips the registry lookup. nil = server already gone.
|
|
25
|
+
worker = Native.worker(server_id, worker_id)
|
|
26
|
+
return unless worker
|
|
23
27
|
if batch_size <= 1
|
|
24
|
-
env =
|
|
25
|
-
env = handle_one(env,
|
|
28
|
+
env = worker.take_one
|
|
29
|
+
env = handle_one(env, worker, app, hooks) while env
|
|
26
30
|
else
|
|
27
|
-
batch =
|
|
28
|
-
batch = process(batch,
|
|
31
|
+
batch = worker.take_batch(batch_size)
|
|
32
|
+
batch = process(batch, worker, app, batch_size, hooks) while batch
|
|
29
33
|
end
|
|
30
34
|
end
|
|
31
35
|
|
|
@@ -35,22 +39,21 @@ module Kino
|
|
|
35
39
|
NOT_FUSED = Object.new.freeze
|
|
36
40
|
|
|
37
41
|
# Handle one request; returns the next env (fused take) or nil.
|
|
38
|
-
def handle_one(env,
|
|
42
|
+
def handle_one(env, worker, app, hooks)
|
|
39
43
|
result = serve(env, app, hooks) do |request, status, headers, chunks|
|
|
40
|
-
request.respond_and_take_one(
|
|
44
|
+
request.respond_and_take_one(worker, status, headers, chunks)
|
|
41
45
|
end
|
|
42
|
-
result.equal?(NOT_FUSED) ?
|
|
46
|
+
result.equal?(NOT_FUSED) ? worker.take_one : result
|
|
43
47
|
end
|
|
44
48
|
|
|
45
49
|
# Handle every env in the batch; returns the next batch (the last
|
|
46
50
|
# simple response rides the fused respond_and_take) or nil on shutdown.
|
|
47
|
-
def process(batch,
|
|
51
|
+
def process(batch, worker, app, batch_size, hooks)
|
|
48
52
|
last = batch.size - 1
|
|
49
53
|
batch.each_with_index do |env, index|
|
|
50
54
|
result = serve(env, app, hooks) do |request, status, headers, chunks|
|
|
51
55
|
if index == last
|
|
52
|
-
request.respond_and_take(
|
|
53
|
-
status, headers, chunks)
|
|
56
|
+
request.respond_and_take(worker, batch_size, status, headers, chunks)
|
|
54
57
|
else
|
|
55
58
|
request.send_simple(status, headers, chunks)
|
|
56
59
|
NOT_FUSED
|
|
@@ -58,7 +61,7 @@ module Kino
|
|
|
58
61
|
end
|
|
59
62
|
return result if index == last && !result.equal?(NOT_FUSED)
|
|
60
63
|
end
|
|
61
|
-
|
|
64
|
+
worker.take_batch(batch_size)
|
|
62
65
|
end
|
|
63
66
|
|
|
64
67
|
# Run one request through the app. Complete bodies are yielded so the
|
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
|
|
@@ -130,6 +132,7 @@ module Kino
|
|
|
130
132
|
def io_threads: (int? count) -> untyped
|
|
131
133
|
def tokio_threads: (int count) -> untyped
|
|
132
134
|
def tls: (cert: String, key: String) -> untyped
|
|
135
|
+
def http2: (?boolish enabled) -> untyped
|
|
133
136
|
def environment: (String | Symbol env) -> untyped
|
|
134
137
|
def pidfile: (String path) -> untyped
|
|
135
138
|
def control_bind: (String addr) -> String
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
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: aarch64-linux
|
|
6
6
|
authors:
|
|
7
7
|
- Yaroslav Markin
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-08
|
|
11
|
+
date: 2026-09-08 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: logger
|
|
@@ -38,6 +38,20 @@ dependencies:
|
|
|
38
38
|
- - ">="
|
|
39
39
|
- !ruby/object:Gem::Version
|
|
40
40
|
version: '3.1'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: httpx
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '1.3'
|
|
48
|
+
type: :development
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '1.3'
|
|
41
55
|
- !ruby/object:Gem::Dependency
|
|
42
56
|
name: rackup
|
|
43
57
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -166,12 +180,16 @@ files:
|
|
|
166
180
|
- lib/kino/kino.so
|
|
167
181
|
- lib/kino/log.rb
|
|
168
182
|
- lib/kino/logger.rb
|
|
183
|
+
- lib/kino/monitor.rb
|
|
169
184
|
- lib/kino/null_input.rb
|
|
185
|
+
- lib/kino/pool_scaler.rb
|
|
170
186
|
- lib/kino/quarantine_monitor.rb
|
|
171
187
|
- lib/kino/ractor_supervisor.rb
|
|
172
188
|
- lib/kino/server.rb
|
|
189
|
+
- lib/kino/slot_bank.rb
|
|
173
190
|
- lib/kino/stream.rb
|
|
174
191
|
- lib/kino/templates/kino.rb.tt
|
|
192
|
+
- lib/kino/threaded_pool.rb
|
|
175
193
|
- lib/kino/version.rb
|
|
176
194
|
- lib/kino/worker.rb
|
|
177
195
|
- lib/kino/worker_hooks.rb
|