kino 0.1.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 +7 -0
- data/.yardopts +14 -0
- data/CHANGELOG.md +54 -0
- data/Cargo.lock +993 -0
- data/Cargo.toml +15 -0
- data/LICENSE.txt +21 -0
- data/README.md +384 -0
- data/doc/README.md +6 -0
- data/doc/architecture.md +161 -0
- data/doc/benchmarks.md +321 -0
- data/doc/rails-on-ractors.md +50 -0
- data/doc/why-kino.md +91 -0
- data/exe/kino +26 -0
- data/ext/kino/Cargo.toml +49 -0
- data/ext/kino/build.rs +5 -0
- data/ext/kino/extconf.rb +6 -0
- data/ext/kino/src/env_strings.rs +318 -0
- data/ext/kino/src/gvl.rs +103 -0
- data/ext/kino/src/lib.rs +90 -0
- data/ext/kino/src/logsink.rs +155 -0
- data/ext/kino/src/queue.rs +207 -0
- data/ext/kino/src/registry.rs +268 -0
- data/ext/kino/src/request.rs +432 -0
- data/ext/kino/src/response.rs +214 -0
- data/ext/kino/src/server.rs +621 -0
- data/ext/kino/src/style.rs +87 -0
- data/ext/kino/src/test_support.rs +82 -0
- data/ext/kino/src/timer.rs +57 -0
- data/ext/kino/src/tls.rs +96 -0
- data/lib/kino/check.rb +199 -0
- data/lib/kino/cli.rb +254 -0
- data/lib/kino/configuration.rb +190 -0
- data/lib/kino/errors_stream.rb +25 -0
- data/lib/kino/input.rb +77 -0
- data/lib/kino/logger.rb +56 -0
- data/lib/kino/null_input.rb +37 -0
- data/lib/kino/ractor_supervisor.rb +103 -0
- data/lib/kino/server.rb +271 -0
- data/lib/kino/stream.rb +61 -0
- data/lib/kino/templates/kino.rb.tt +141 -0
- data/lib/kino/version.rb +6 -0
- data/lib/kino/worker.rb +124 -0
- data/lib/kino.rb +53 -0
- data/sig/kino.rbs +178 -0
- metadata +219 -0
data/lib/kino/server.rb
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kino
|
|
4
|
+
# Public server API. All network I/O lives in Rust (tokio + hyper); this
|
|
5
|
+
# class only manages lifecycle and the Ruby worker pool.
|
|
6
|
+
#
|
|
7
|
+
# Topology is Puma-style two-level: `workers` ractors × `threads` threads
|
|
8
|
+
# per ractor in :ractor mode; the same total capacity flattened onto plain
|
|
9
|
+
# Threads in :threaded mode (which runs ANY Rack app, Rails included).
|
|
10
|
+
class Server
|
|
11
|
+
# @return [Integer, nil] the bound port (nil until #start; the actual
|
|
12
|
+
# port when configured with port 0)
|
|
13
|
+
attr_reader :port
|
|
14
|
+
|
|
15
|
+
# @return [Symbol] the resolved dispatch mode, :ractor or :threaded
|
|
16
|
+
attr_reader :mode
|
|
17
|
+
|
|
18
|
+
# @return [String] the bind address
|
|
19
|
+
attr_reader :bind
|
|
20
|
+
|
|
21
|
+
# @return [Boolean] whether TLS termination is configured
|
|
22
|
+
def tls?
|
|
23
|
+
!@tls.nil?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Settings precedence: explicit kwargs > config_file DSL > defaults.
|
|
27
|
+
#
|
|
28
|
+
# @param app [#call] a Rack 3 application
|
|
29
|
+
# @param config_file [String, nil] path to a kino.rb config file
|
|
30
|
+
# @param options [Hash] any {Kino::Configuration} setting, e.g. port:,
|
|
31
|
+
# workers:, threads:, mode:, request_timeout:, tls: cert/key Hash
|
|
32
|
+
# @example
|
|
33
|
+
# Kino::Server.new(app, config_file: "kino.rb", port: 3000)
|
|
34
|
+
def initialize(app, config_file: nil, **options)
|
|
35
|
+
config = Configuration.new
|
|
36
|
+
config.load_file(config_file) if config_file
|
|
37
|
+
config.merge!(options)
|
|
38
|
+
settings = config.to_h
|
|
39
|
+
|
|
40
|
+
@app = app
|
|
41
|
+
@bind = settings[:bind]
|
|
42
|
+
@requested_port = settings[:port]
|
|
43
|
+
@workers = Integer(settings[:workers])
|
|
44
|
+
@threads = Integer(settings[:threads])
|
|
45
|
+
@mode = resolve_mode(settings[:mode])
|
|
46
|
+
@queue_depth = Integer(settings[:queue_depth])
|
|
47
|
+
@queue_timeout_ms = (Float(settings[:queue_timeout]) * 1000).round
|
|
48
|
+
@request_timeout_ms = settings[:request_timeout] ? (Float(settings[:request_timeout]) * 1000).round : 0
|
|
49
|
+
@batch = [Integer(settings[:batch]), 1].max
|
|
50
|
+
@lanes = !!settings[:lanes]
|
|
51
|
+
@log_requests = !!settings[:log_requests]
|
|
52
|
+
@shutdown_timeout = settings[:shutdown_timeout]
|
|
53
|
+
@tokio_threads = settings[:tokio_threads]
|
|
54
|
+
@tls = validate_tls(settings[:tls])
|
|
55
|
+
@pidfile = settings[:pidfile]
|
|
56
|
+
@worker_threads = []
|
|
57
|
+
@supervisor = nil
|
|
58
|
+
@started = false
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Bind, boot the native front-end, and spawn the worker pool.
|
|
62
|
+
# @return [self]
|
|
63
|
+
# @raise [Kino::Error] when already started
|
|
64
|
+
# @raise [Kino::UnshareableAppError] in forced :ractor mode with an
|
|
65
|
+
# unshareable app
|
|
66
|
+
def start
|
|
67
|
+
raise Error, "server already started" if @started
|
|
68
|
+
|
|
69
|
+
@id, @port = Native.server_start(
|
|
70
|
+
bind: @bind, port: @requested_port,
|
|
71
|
+
queue_depth: @queue_depth, queue_timeout_ms: @queue_timeout_ms,
|
|
72
|
+
request_timeout_ms: @request_timeout_ms,
|
|
73
|
+
tokio_threads: @tokio_threads,
|
|
74
|
+
tls_cert: @tls&.fetch(:cert), tls_key: @tls&.fetch(:key),
|
|
75
|
+
lanes: @lanes, log_requests: @log_requests
|
|
76
|
+
)
|
|
77
|
+
File.write(@pidfile, "#{Process.pid}\n") if @pidfile
|
|
78
|
+
if @mode == :ractor
|
|
79
|
+
@supervisor = RactorSupervisor.new(@id, @app, workers: @workers, threads: @threads, batch: @batch).start
|
|
80
|
+
else
|
|
81
|
+
@worker_threads = (@workers * @threads).times.map do
|
|
82
|
+
worker_id = Native.register_worker(@id)
|
|
83
|
+
Thread.new { Worker.run(@id, worker_id, @app, @batch) }
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
@started = true
|
|
87
|
+
self
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Graceful shutdown: stop accepting, drain in-flight work up to the
|
|
91
|
+
# deadline, then escalate: abort remaining clients (500), interrupt
|
|
92
|
+
# blocked workers, kill stragglers; and tear down the runtime. Always
|
|
93
|
+
# returns by ~deadline + a small epsilon; idempotent.
|
|
94
|
+
#
|
|
95
|
+
# @param timeout [Numeric, nil] drain deadline in seconds (default:
|
|
96
|
+
# the configured shutdown_timeout)
|
|
97
|
+
# @return [nil]
|
|
98
|
+
def shutdown(timeout: nil)
|
|
99
|
+
return unless @started
|
|
100
|
+
|
|
101
|
+
deadline = monotonic_now + (timeout || @shutdown_timeout)
|
|
102
|
+
Native.stop_accepting(@id)
|
|
103
|
+
|
|
104
|
+
# Drain: wait for queued + in-flight to reach zero, bounded by deadline.
|
|
105
|
+
until monotonic_now >= deadline
|
|
106
|
+
queued, in_flight = Native.queue_stats(@id)
|
|
107
|
+
break if queued.zero? && in_flight.zero?
|
|
108
|
+
|
|
109
|
+
sleep 0.01
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Idle workers see the closed queue and exit their loops.
|
|
113
|
+
Native.close_queue(@id)
|
|
114
|
+
join_workers(deadline)
|
|
115
|
+
|
|
116
|
+
unless workers_done?
|
|
117
|
+
# Past the deadline with stuck handlers: free the clients first,
|
|
118
|
+
# then try to unblock and reap the workers.
|
|
119
|
+
Native.abort_all_inflight(@id)
|
|
120
|
+
Native.interrupt_all_workers(@id)
|
|
121
|
+
join_workers(monotonic_now + 0.2)
|
|
122
|
+
kill_stragglers
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
Native.shutdown_runtime(@id, 1_000)
|
|
126
|
+
@worker_threads.clear
|
|
127
|
+
@started = false
|
|
128
|
+
File.delete(@pidfile) if @pidfile && File.exist?(@pidfile)
|
|
129
|
+
nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Block until every worker has exited (i.e. until shutdown).
|
|
133
|
+
# @return [void]
|
|
134
|
+
def wait
|
|
135
|
+
@supervisor ? @supervisor.join : @worker_threads.each(&:join)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Production entry point: start, print the banner, trap INT/TERM for
|
|
139
|
+
# graceful shutdown (second signal force-exits), block until done.
|
|
140
|
+
# The `kino` CLI funnels into this too (CLI#serve).
|
|
141
|
+
#
|
|
142
|
+
# @param app [#call] a Rack 3 application
|
|
143
|
+
# @param opts [Hash] see #initialize
|
|
144
|
+
# @return [Kino::Server] the (stopped) server, after shutdown
|
|
145
|
+
def self.run(app, **opts)
|
|
146
|
+
server = new(app, **opts)
|
|
147
|
+
CLI.opening_credits
|
|
148
|
+
server.start
|
|
149
|
+
CLI.action!(server)
|
|
150
|
+
CLI.fin_at_exit
|
|
151
|
+
trap_signals(server)
|
|
152
|
+
server.wait
|
|
153
|
+
server
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Signal handling shared by Server.run and the kino CLI: INT/TERM drain
|
|
157
|
+
# gracefully (a second signal force-exits), USR1 prints a stats line.
|
|
158
|
+
#
|
|
159
|
+
# @param server [Kino::Server]
|
|
160
|
+
# @return [void]
|
|
161
|
+
def self.trap_signals(server)
|
|
162
|
+
# kill -USR1 <pid> prints a one-line stats snapshot (find the pid in
|
|
163
|
+
# the pidfile when configured).
|
|
164
|
+
trap("USR1") do
|
|
165
|
+
Thread.new { $stdout.puts Kino::CLI.stats_line(server.stats) }
|
|
166
|
+
end
|
|
167
|
+
signaled = false
|
|
168
|
+
%w[INT TERM].each do |signal|
|
|
169
|
+
trap(signal) do
|
|
170
|
+
Process.exit!(1) if signaled
|
|
171
|
+
signaled = true
|
|
172
|
+
$stderr.write("Kino: draining (signal again to force exit)\n")
|
|
173
|
+
# Trap context forbids mutexes; do the real work on a thread.
|
|
174
|
+
Thread.new { server.shutdown }
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Live snapshot. Counters come from the native layer (one relaxed
|
|
180
|
+
# atomic per request); config echo makes the line self-describing.
|
|
181
|
+
#
|
|
182
|
+
# @return [Hash{Symbol => Object}] mode, lanes, workers, threads,
|
|
183
|
+
# batch, respawns; plus queued, in_flight, served, rejected,
|
|
184
|
+
# timeouts (and lane_depths in lanes mode) once started
|
|
185
|
+
def stats
|
|
186
|
+
base = {
|
|
187
|
+
mode: @mode, lanes: @lanes, workers: @workers, threads: @threads,
|
|
188
|
+
batch: @batch, respawns: @supervisor ? @supervisor.respawns : 0
|
|
189
|
+
}
|
|
190
|
+
return base unless @started
|
|
191
|
+
|
|
192
|
+
queued, in_flight, served, rejected, timeouts, lane_depths = Native.server_stats(@id)
|
|
193
|
+
base.merge!(queued:, in_flight:, served:, rejected:, timeouts:)
|
|
194
|
+
base[:lane_depths] = lane_depths if lane_depths
|
|
195
|
+
base
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
private
|
|
199
|
+
|
|
200
|
+
def validate_tls(tls)
|
|
201
|
+
return nil if tls.nil?
|
|
202
|
+
unless tls.is_a?(Hash) && tls[:cert] && tls[:key]
|
|
203
|
+
raise ArgumentError, "tls: expects { cert:, key: } (file paths or inline PEM)"
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
{cert: String(tls[:cert]), key: String(tls[:key])}
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def monotonic_now
|
|
210
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def join_workers(deadline)
|
|
214
|
+
if @supervisor
|
|
215
|
+
@supervisor.shutdown([deadline - monotonic_now, 0].max)
|
|
216
|
+
else
|
|
217
|
+
@worker_threads.each do |thread|
|
|
218
|
+
thread.join([deadline - monotonic_now, 0.01].max)
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def workers_done?
|
|
224
|
+
if @supervisor
|
|
225
|
+
@supervisor.done?
|
|
226
|
+
else
|
|
227
|
+
@worker_threads.none?(&:alive?)
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def kill_stragglers
|
|
232
|
+
if @supervisor
|
|
233
|
+
# Ractors cannot be force-killed; their clients were already freed
|
|
234
|
+
# by abort_all_inflight. The stuck ractor leaks until process exit.
|
|
235
|
+
Native.log_error("shutdown deadline passed with stuck ractor workers") unless @supervisor.done?
|
|
236
|
+
else
|
|
237
|
+
@worker_threads.each { |thread| thread.kill if thread.alive? }
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Policy (mode resolution): when is an app safe for ractor
|
|
242
|
+
# dispatch, and how loudly do we fall back? Current policy: trust
|
|
243
|
+
# Ractor.shareable? on :auto with a stderr warning on fallback; forcing
|
|
244
|
+
# :ractor with an unshareable app is an error, and we never
|
|
245
|
+
# make_shareable the user's app behind their back (deep-freezing
|
|
246
|
+
# someone's object graph is not a server's call to make).
|
|
247
|
+
def resolve_mode(requested)
|
|
248
|
+
case requested
|
|
249
|
+
when :threaded
|
|
250
|
+
:threaded
|
|
251
|
+
when :ractor
|
|
252
|
+
unless Ractor.shareable?(@app)
|
|
253
|
+
raise UnshareableAppError,
|
|
254
|
+
"mode: :ractor requires a Ractor-shareable app (frozen middleware, " \
|
|
255
|
+
"Ractor.shareable_proc endpoints); try Ractor.make_shareable(app) " \
|
|
256
|
+
"or mode: :threaded"
|
|
257
|
+
end
|
|
258
|
+
:ractor
|
|
259
|
+
when :auto
|
|
260
|
+
if Ractor.shareable?(@app)
|
|
261
|
+
:ractor
|
|
262
|
+
else
|
|
263
|
+
warn "Kino: app is not Ractor-shareable; falling back to mode: :threaded"
|
|
264
|
+
:threaded
|
|
265
|
+
end
|
|
266
|
+
else
|
|
267
|
+
raise ArgumentError, "mode must be :auto, :ractor, or :threaded (got #{requested.inspect})"
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
data/lib/kino/stream.rb
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kino
|
|
4
|
+
# @private
|
|
5
|
+
# The stream handed to Rack 3 streaming bodies (`body.call(stream)`).
|
|
6
|
+
# Rack 3 requires it to be full-duplex: writes go to the native response
|
|
7
|
+
# channel (a slow client blocks the writer with the GVL released), reads
|
|
8
|
+
# pull the remaining request body through the request's own rack.input.
|
|
9
|
+
class Stream
|
|
10
|
+
def initialize(request, input)
|
|
11
|
+
@request = request
|
|
12
|
+
@input = input
|
|
13
|
+
@read_closed = false
|
|
14
|
+
@write_closed = false
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def read(length = nil, buffer = nil)
|
|
18
|
+
raise IOError, "stream is closed for reading" if @read_closed
|
|
19
|
+
|
|
20
|
+
@input.read(length, buffer)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def write(chunk)
|
|
24
|
+
raise IOError, "stream is closed for writing" if @write_closed
|
|
25
|
+
|
|
26
|
+
@request.write_chunk(chunk)
|
|
27
|
+
chunk.bytesize
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def <<(chunk)
|
|
31
|
+
write(chunk)
|
|
32
|
+
self
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def flush
|
|
36
|
+
self
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def close_read
|
|
40
|
+
@read_closed = true
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def close_write
|
|
45
|
+
return if @write_closed
|
|
46
|
+
|
|
47
|
+
@write_closed = true
|
|
48
|
+
@request.finish
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def close
|
|
53
|
+
close_read
|
|
54
|
+
close_write
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def closed?
|
|
58
|
+
@read_closed && @write_closed
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Kino configuration.
|
|
4
|
+
# Generated by `kino --init`.
|
|
5
|
+
#
|
|
6
|
+
# Every setting below is shown with its default value, commented out:
|
|
7
|
+
# the file is a valid no-op until you uncomment something. Precedence:
|
|
8
|
+
# explicit Server.new kwargs / CLI flags > this file > built-in defaults.
|
|
9
|
+
|
|
10
|
+
## Network
|
|
11
|
+
|
|
12
|
+
# Address to listen on. Use "0.0.0.0" to accept non-local connections.
|
|
13
|
+
# bind "127.0.0.1"
|
|
14
|
+
|
|
15
|
+
# Port to listen on. 0 picks an ephemeral port (readable via server.port).
|
|
16
|
+
# The `kino` CLI defaults this to 9292 when nothing else sets it.
|
|
17
|
+
# port 9292
|
|
18
|
+
|
|
19
|
+
# TLS termination (rustls, in Rust; never blocks a Ruby thread).
|
|
20
|
+
# Values are file paths or inline PEM strings. ALPN is http/1.1.
|
|
21
|
+
# tls cert: "config/certs/server.pem", key: "config/certs/server.key"
|
|
22
|
+
|
|
23
|
+
## Topology
|
|
24
|
+
|
|
25
|
+
# Puma-style two-level topology: `workers` × `threads`.
|
|
26
|
+
#
|
|
27
|
+
# In :ractor mode, `workers` is the number of worker Ractors: true
|
|
28
|
+
# multi-core parallelism for Ruby CPU work, one per core is a good start.
|
|
29
|
+
# In :threaded mode the same total (workers × threads) runs as plain
|
|
30
|
+
# Threads on the main ractor.
|
|
31
|
+
|
|
32
|
+
# Defaults to the number of CPU cores (Etc.nprocessors).
|
|
33
|
+
# workers 8
|
|
34
|
+
|
|
35
|
+
# Threads per worker. Threads inside one ractor share its lock, so they
|
|
36
|
+
# only add concurrency where handlers block on I/O (database calls, HTTP).
|
|
37
|
+
# CPU-bound apps gain nothing past 1 (and pay a lock-handoff tax: threads 1
|
|
38
|
+
# measured +17% on fast handlers). I/O-heavy apps want more SLOTS overall -
|
|
39
|
+
# in :ractor mode prefer raising `workers` over `threads` (slots are cheap,
|
|
40
|
+
# no fork memory): 32 workers x 1 thread beat 8x3 by +35% on waits.
|
|
41
|
+
# threads 3
|
|
42
|
+
|
|
43
|
+
## Dispatch mode
|
|
44
|
+
#
|
|
45
|
+
# :auto: :ractor when the app is Ractor-shareable, else :threaded
|
|
46
|
+
# (with a warning). Note: a Class used as a Rack app always
|
|
47
|
+
# counts as "shareable" even if calling it touches unshareable
|
|
48
|
+
# state; force :threaded for those.
|
|
49
|
+
# :ractor: require a Ractor-shareable app; raises
|
|
50
|
+
# Kino::UnshareableAppError otherwise. The app must capture
|
|
51
|
+
# nothing mutable: frozen middleware, Ractor.shareable_proc
|
|
52
|
+
# endpoints.
|
|
53
|
+
# :threaded: run ANY Rack app (Rails included) on a classic thread pool.
|
|
54
|
+
# mode :auto
|
|
55
|
+
|
|
56
|
+
## Backpressure
|
|
57
|
+
|
|
58
|
+
# Bounded request queue between the Rust front-end and Ruby workers.
|
|
59
|
+
# When it stays full past queue_timeout, clients get an immediate 503
|
|
60
|
+
# instead of waiting forever.
|
|
61
|
+
# queue_depth 1024
|
|
62
|
+
|
|
63
|
+
# Seconds a request may wait for queue space before the 503.
|
|
64
|
+
# queue_timeout 1.0
|
|
65
|
+
|
|
66
|
+
# Seconds the app gets to produce a response before the client receives a
|
|
67
|
+
# 504 instead. Off by default (nil = wait forever). The handler is NOT
|
|
68
|
+
# killed - its late response is dropped and its slot stays busy until it
|
|
69
|
+
# returns, so size this above your slowest legitimate endpoint.
|
|
70
|
+
# request_timeout 30
|
|
71
|
+
|
|
72
|
+
# Requests a worker may grab per queue visit. Values above 1 squeeze more
|
|
73
|
+
# throughput out of uniformly fast handlers, but add head-of-line blocking
|
|
74
|
+
# behind slow ones and stretch the effective queue depth - leave at 1
|
|
75
|
+
# unless your handlers are all sub-millisecond.
|
|
76
|
+
# batch 1
|
|
77
|
+
|
|
78
|
+
# EXPERIMENTAL lane dispatch: per-worker queues with awake-preferring
|
|
79
|
+
# assignment and work stealing. Cuts per-request wakeups for uniformly
|
|
80
|
+
# fast handlers; semantics under overload are slightly different (per-lane
|
|
81
|
+
# caps with brief dispatcher retries instead of one global queue).
|
|
82
|
+
# lanes false
|
|
83
|
+
|
|
84
|
+
# Native access log: one line per request to stdout, written by a
|
|
85
|
+
# Rust-side flusher thread - request threads never block on the log.
|
|
86
|
+
#
|
|
87
|
+
# On color terminals lines are tinted by status class (2xx green,
|
|
88
|
+
# 3xx yellow, 4xx maroon, 5xx bright red). This is the SERVER's view - it
|
|
89
|
+
# includes the 503 rejections your app never sees - and it interleaves
|
|
90
|
+
# cleanly with your app's own log (e.g. Rails') on stdout. See also
|
|
91
|
+
# Kino::Logger for routing the app log through the same async sink.
|
|
92
|
+
#
|
|
93
|
+
# Try enabling it in the development environment.
|
|
94
|
+
# log_requests false
|
|
95
|
+
|
|
96
|
+
## Lifecycle
|
|
97
|
+
|
|
98
|
+
# Graceful-shutdown drain deadline in seconds: in-flight requests get this
|
|
99
|
+
# long to finish; past it, their clients receive 500s and workers are
|
|
100
|
+
# reaped. A second INT/TERM force-exits immediately.
|
|
101
|
+
# shutdown_timeout 30
|
|
102
|
+
|
|
103
|
+
# Write the master PID here on start; removed on graceful shutdown.
|
|
104
|
+
# pidfile "tmp/pids/kino.pid"
|
|
105
|
+
|
|
106
|
+
## Runtime
|
|
107
|
+
|
|
108
|
+
# Threads for the tokio (Rust I/O) runtime. Default (nil) lets tokio use
|
|
109
|
+
# one per core: right for I/O-heavy apps. For CPU-heavy apps this is a
|
|
110
|
+
# real lever: `tokio_threads 1` + `threads 1` measured +26% on a pure-CPU
|
|
111
|
+
# benchmark (every spare thread is Ruby work you didn't run).
|
|
112
|
+
# tokio_threads 4
|
|
113
|
+
|
|
114
|
+
## App
|
|
115
|
+
|
|
116
|
+
# Rackup file the `kino` CLI loads (positional CLI argument wins).
|
|
117
|
+
# rackup "config.ru"
|
|
118
|
+
|
|
119
|
+
# Sets RACK_ENV (unless already set) before the app is loaded by the CLI.
|
|
120
|
+
# environment "production"
|
|
121
|
+
|
|
122
|
+
## Rails
|
|
123
|
+
#
|
|
124
|
+
# Rails runs on Kino TODAY in :threaded mode; uncomment for a Rails app:
|
|
125
|
+
#
|
|
126
|
+
# mode :threaded
|
|
127
|
+
# environment "production"
|
|
128
|
+
# threads 5 # match your database pool size
|
|
129
|
+
#
|
|
130
|
+
# Recommended Rails-side settings to pair with Kino:
|
|
131
|
+
# - config.eager_load = true and no code reloading (production defaults):
|
|
132
|
+
# Kino's workers serve concurrently; lazy class loading under
|
|
133
|
+
# concurrency is slow and, in ractor mode, unsafe.
|
|
134
|
+
# - Database pool >= workers × threads (config/database.yml `pool:`).
|
|
135
|
+
# - Rails.logger goes to stdout/stderr or a thread-safe device.
|
|
136
|
+
#
|
|
137
|
+
# Rails main is being ractorized, but
|
|
138
|
+
# Rails.application still captures unshareable state at boot; known
|
|
139
|
+
# blockers are documented in Kino's README. Track rails/rails main; when
|
|
140
|
+
# Ractor.make_shareable(Rails.application) succeeds, `mode :ractor` here
|
|
141
|
+
# is all you'll need to change.
|
data/lib/kino/version.rb
ADDED
data/lib/kino/worker.rb
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kino
|
|
4
|
+
# @private
|
|
5
|
+
# The request loop. Identical for threaded and ractor modes.
|
|
6
|
+
#
|
|
7
|
+
# The default (batch 1) hot path allocates one Hash per request: the env
|
|
8
|
+
# arrives with the native request handle embedded under "kino.request",
|
|
9
|
+
# and the common complete-body response rides the fused
|
|
10
|
+
# respond_and_take_one call: ~one FFI crossing per request, no arrays.
|
|
11
|
+
#
|
|
12
|
+
# batch > 1 trades fairness for throughput: a worker grabs up to that
|
|
13
|
+
# many already-queued requests per crossing, adding head-of-line blocking
|
|
14
|
+
# behind slow handlers and stretching effective queue depth.
|
|
15
|
+
module Worker
|
|
16
|
+
RACK_INPUT = "rack.input"
|
|
17
|
+
KINO_REQUEST = "kino.request"
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def run(server_id, worker_id, app, batch_size = 1)
|
|
22
|
+
if batch_size <= 1
|
|
23
|
+
env = Native.take_one(server_id, worker_id)
|
|
24
|
+
env = handle_one(env, server_id, worker_id, app) while env
|
|
25
|
+
else
|
|
26
|
+
batch = Native.take_batch(server_id, worker_id, batch_size)
|
|
27
|
+
batch = process(batch, server_id, worker_id, app, batch_size) while batch
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# serve() returns this when the response did NOT ride a fused
|
|
32
|
+
# respond-and-take (streaming body or app error) and the caller must
|
|
33
|
+
# take the next request itself. Frozen: worker ractors read it.
|
|
34
|
+
NOT_FUSED = Object.new.freeze
|
|
35
|
+
|
|
36
|
+
# Handle one request; returns the next env (fused take) or nil.
|
|
37
|
+
def handle_one(env, server_id, worker_id, app)
|
|
38
|
+
result = serve(env, app) do |request, status, headers, chunks|
|
|
39
|
+
request.respond_and_take_one(server_id, worker_id, status, headers, chunks)
|
|
40
|
+
end
|
|
41
|
+
result.equal?(NOT_FUSED) ? Native.take_one(server_id, worker_id) : result
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Handle every env in the batch; returns the next batch (the last
|
|
45
|
+
# simple response rides the fused respond_and_take) or nil on shutdown.
|
|
46
|
+
def process(batch, server_id, worker_id, app, batch_size)
|
|
47
|
+
last = batch.size - 1
|
|
48
|
+
batch.each_with_index do |env, index|
|
|
49
|
+
result = serve(env, app) do |request, status, headers, chunks|
|
|
50
|
+
if index == last
|
|
51
|
+
request.respond_and_take(server_id, worker_id, batch_size,
|
|
52
|
+
status, headers, chunks)
|
|
53
|
+
else
|
|
54
|
+
request.send_simple(status, headers, chunks)
|
|
55
|
+
NOT_FUSED
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
return result if index == last && !result.equal?(NOT_FUSED)
|
|
59
|
+
end
|
|
60
|
+
Native.take_batch(server_id, worker_id, batch_size)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Run one request through the app. Complete bodies are yielded so the
|
|
64
|
+
# caller picks plain vs fused delivery (the block's return value passes
|
|
65
|
+
# through after the body is closed); streaming bodies are delivered
|
|
66
|
+
# here and return NOT_FUSED. App errors must never kill the worker;
|
|
67
|
+
# hard crashes (Exception) are the supervisor's job; and `abort` does
|
|
68
|
+
# the right thing whether or not the response head already went out.
|
|
69
|
+
def serve(env, app)
|
|
70
|
+
request = env[KINO_REQUEST]
|
|
71
|
+
env[RACK_INPUT] ||= Input.new(request)
|
|
72
|
+
status, headers, body = app.call(env)
|
|
73
|
+
|
|
74
|
+
if body.respond_to?(:to_ary)
|
|
75
|
+
result = yield(request, status.to_i, headers, join_chunks(body.to_ary))
|
|
76
|
+
body.close if body.respond_to?(:close)
|
|
77
|
+
result
|
|
78
|
+
else
|
|
79
|
+
deliver_streaming(request, status.to_i, headers, body, env[RACK_INPUT])
|
|
80
|
+
NOT_FUSED
|
|
81
|
+
end
|
|
82
|
+
rescue => e
|
|
83
|
+
Native.log_error("#{e.class}: #{e.message}")
|
|
84
|
+
request.abort
|
|
85
|
+
NOT_FUSED
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def deliver_streaming(request, status, headers, body, input)
|
|
89
|
+
request.send_headers(status, headers)
|
|
90
|
+
if body.respond_to?(:call) && !body.respond_to?(:each)
|
|
91
|
+
# Rack 3 streaming body: the app drives a full-duplex stream whose
|
|
92
|
+
# read side is the request's existing rack.input (a fresh Input
|
|
93
|
+
# here would strand anything the app already buffered from it).
|
|
94
|
+
stream = Stream.new(request, input)
|
|
95
|
+
begin
|
|
96
|
+
body.call(stream)
|
|
97
|
+
ensure
|
|
98
|
+
stream.close
|
|
99
|
+
end
|
|
100
|
+
else
|
|
101
|
+
# Enumerable body: chunked transfer unless the app set content-length.
|
|
102
|
+
begin
|
|
103
|
+
body.each { |chunk| request.write_chunk(chunk) }
|
|
104
|
+
ensure
|
|
105
|
+
request.finish
|
|
106
|
+
body.close if body.respond_to?(:close)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def join_chunks(chunks)
|
|
112
|
+
# Single-chunk bodies (the common case) skip the join copy entirely:
|
|
113
|
+
# the native layer reads raw bytes, so encoding doesn't matter.
|
|
114
|
+
return chunks.first || "" if chunks.size <= 1
|
|
115
|
+
|
|
116
|
+
joined = (+"").force_encoding(Encoding::BINARY)
|
|
117
|
+
chunks.each { |chunk| joined << chunk.b }
|
|
118
|
+
joined
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private_class_method :handle_one, :process, :serve, :deliver_streaming,
|
|
122
|
+
:join_chunks
|
|
123
|
+
end
|
|
124
|
+
end
|
data/lib/kino.rb
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "kino/version"
|
|
4
|
+
require "kino/kino"
|
|
5
|
+
|
|
6
|
+
# A high-performance Ractor web server for Ruby 4.0+: Rack 3-based, with
|
|
7
|
+
# a Rust (tokio + hyper) front-end and Ractor-parallel Ruby workers, plus
|
|
8
|
+
# a threaded fallback mode for apps (such as Rails) that are not
|
|
9
|
+
# Ractor-shareable.
|
|
10
|
+
#
|
|
11
|
+
# The public API is {Kino::Server}, {Kino::Configuration}, {Kino::Check},
|
|
12
|
+
# {Kino::Logger}, and {Kino.sleep}; the `kino` executable is implemented
|
|
13
|
+
# by {Kino::CLI}.
|
|
14
|
+
module Kino
|
|
15
|
+
# Base class for all errors raised by Kino.
|
|
16
|
+
class Error < StandardError; end
|
|
17
|
+
|
|
18
|
+
# Raised when mode: :ractor is forced but the app is not Ractor-shareable.
|
|
19
|
+
class UnshareableAppError < Error; end
|
|
20
|
+
|
|
21
|
+
# High-resolution sleep that bypasses the VM timer (whose wakeups inside
|
|
22
|
+
# non-main ractors are coarse: ~+2.5ms on Linux). Sleeps on the OS clock
|
|
23
|
+
# with the GVL released; chunked so Thread#kill and shutdown interrupts
|
|
24
|
+
# are honored between chunks.
|
|
25
|
+
#
|
|
26
|
+
# @param seconds [Numeric] how long to sleep; must be non-negative
|
|
27
|
+
# @return [nil]
|
|
28
|
+
# @raise [ArgumentError] for negative or non-numeric durations
|
|
29
|
+
def self.sleep(seconds)
|
|
30
|
+
remaining = Float(seconds)
|
|
31
|
+
raise ArgumentError, "sleep duration must be non-negative" if remaining.negative?
|
|
32
|
+
|
|
33
|
+
remaining = Native.sleep_chunk(remaining) while remaining.positive?
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
require_relative "kino/cli"
|
|
39
|
+
require_relative "kino/logger"
|
|
40
|
+
require_relative "kino/check"
|
|
41
|
+
require_relative "kino/input"
|
|
42
|
+
require_relative "kino/null_input"
|
|
43
|
+
require_relative "kino/errors_stream"
|
|
44
|
+
require_relative "kino/stream"
|
|
45
|
+
require_relative "kino/configuration"
|
|
46
|
+
require_relative "kino/worker"
|
|
47
|
+
require_relative "kino/ractor_supervisor"
|
|
48
|
+
require_relative "kino/server"
|
|
49
|
+
|
|
50
|
+
# Hand the frozen shareable singletons to the native layer: it sets them
|
|
51
|
+
# straight into each request's env, so the worker loop allocates neither
|
|
52
|
+
# an errors stream nor (for bodyless requests) an input object.
|
|
53
|
+
Kino::Native.register_defaults(Kino::ErrorsStream::INSTANCE, Kino::NullInput::INSTANCE)
|