quicsilver 0.5.0-x86_64-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 +7 -0
- data/.github/workflows/ci.yml +49 -0
- data/.github/workflows/cibuildgem.yaml +117 -0
- data/.gitignore +21 -0
- data/.gitmodules +3 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +175 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +67 -0
- data/LICENSE +21 -0
- data/README.md +261 -0
- data/Rakefile +130 -0
- data/benchmarks/baseline.rb +175 -0
- data/benchmarks/compare.rb +398 -0
- data/benchmarks/components.rb +191 -0
- data/benchmarks/helpers.rb +162 -0
- data/benchmarks/latency.rb +128 -0
- data/benchmarks/loss.rb +193 -0
- data/benchmarks/mobile.rb +125 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/docs/connection_identity.md +141 -0
- data/examples/README.md +58 -0
- data/examples/benchmark.rb +191 -0
- data/examples/connection_pool_demo.rb +47 -0
- data/examples/example_helper.rb +69 -0
- data/examples/falcon_middleware.rb +44 -0
- data/examples/feature_demo.rb +125 -0
- data/examples/grpc_style.rb +97 -0
- data/examples/latency_aware_routing.rb +382 -0
- data/examples/lifecycle_drain.rb +264 -0
- data/examples/minimal_http3_server.rb +14 -0
- data/examples/priorities.rb +60 -0
- data/examples/protocol_http_server.rb +31 -0
- data/examples/rack_http3_server.rb +42 -0
- data/examples/rails_feature_test.rb +280 -0
- data/examples/server_stats.rb +117 -0
- data/examples/simple_client_test.rb +26 -0
- data/examples/streaming_sse.rb +33 -0
- data/examples/trailers.rb +69 -0
- data/examples/webtransport_smoke.rb +546 -0
- data/examples/worker_load_balancing.rb +425 -0
- data/ext/quicsilver/extconf.rb +124 -0
- data/ext/quicsilver/include/msquic.h +2226 -0
- data/ext/quicsilver/include/msquic_posix.h +696 -0
- data/ext/quicsilver/include/quic_sal_stub.h +314 -0
- data/ext/quicsilver/msquic_patch.rb +68 -0
- data/ext/quicsilver/patches/reliable-reset.patch +723 -0
- data/ext/quicsilver/patches/stream-final-size.patch +47 -0
- data/ext/quicsilver/quicsilver.c +2741 -0
- data/lib/quicsilver/3.4/quicsilver.so +0 -0
- data/lib/quicsilver/4.0/quicsilver.so +0 -0
- data/lib/quicsilver/client/client.rb +708 -0
- data/lib/quicsilver/client/connection_pool.rb +202 -0
- data/lib/quicsilver/client/request.rb +190 -0
- data/lib/quicsilver/errors.rb +39 -0
- data/lib/quicsilver/libmsquic.so.2 +0 -0
- data/lib/quicsilver/protocol/adapter.rb +196 -0
- data/lib/quicsilver/protocol/capsule.rb +38 -0
- data/lib/quicsilver/protocol/connect_stream_decoder.rb +75 -0
- data/lib/quicsilver/protocol/control_stream_parser.rb +117 -0
- data/lib/quicsilver/protocol/datagram.rb +40 -0
- data/lib/quicsilver/protocol/frame_parser.rb +141 -0
- data/lib/quicsilver/protocol/frame_reader.rb +84 -0
- data/lib/quicsilver/protocol/frames.rb +454 -0
- data/lib/quicsilver/protocol/priority.rb +65 -0
- data/lib/quicsilver/protocol/qpack/decoder.rb +127 -0
- data/lib/quicsilver/protocol/qpack/encoder.rb +217 -0
- data/lib/quicsilver/protocol/qpack/header_block_decoder.rb +140 -0
- data/lib/quicsilver/protocol/qpack/huffman.rb +459 -0
- data/lib/quicsilver/protocol/request_encoder.rb +50 -0
- data/lib/quicsilver/protocol/request_parser.rb +289 -0
- data/lib/quicsilver/protocol/response_encoder.rb +107 -0
- data/lib/quicsilver/protocol/response_parser.rb +147 -0
- data/lib/quicsilver/protocol/stream_input.rb +98 -0
- data/lib/quicsilver/protocol/stream_output.rb +59 -0
- data/lib/quicsilver/protocol/unidirectional_stream.rb +12 -0
- data/lib/quicsilver/protocol/web_transport.rb +214 -0
- data/lib/quicsilver/rack/context.rb +33 -0
- data/lib/quicsilver/response.rb +40 -0
- data/lib/quicsilver/server/listener_data.rb +14 -0
- data/lib/quicsilver/server/rack_adapter.rb +77 -0
- data/lib/quicsilver/server/receive_queue.rb +106 -0
- data/lib/quicsilver/server/request_handler.rb +149 -0
- data/lib/quicsilver/server/request_registry.rb +63 -0
- data/lib/quicsilver/server/scheduler.rb +50 -0
- data/lib/quicsilver/server/schedulers/fiber_scheduler.rb +79 -0
- data/lib/quicsilver/server/schedulers/thread_scheduler.rb +76 -0
- data/lib/quicsilver/server/server.rb +1080 -0
- data/lib/quicsilver/server/web_transport_flow_control.rb +270 -0
- data/lib/quicsilver/server/web_transport_manager.rb +281 -0
- data/lib/quicsilver/server/web_transport_registry.rb +40 -0
- data/lib/quicsilver/server/web_transport_session.rb +812 -0
- data/lib/quicsilver/server/web_transport_stream.rb +326 -0
- data/lib/quicsilver/transport/configuration.rb +234 -0
- data/lib/quicsilver/transport/connection.rb +594 -0
- data/lib/quicsilver/transport/connection_stats.rb +53 -0
- data/lib/quicsilver/transport/event_loop.rb +74 -0
- data/lib/quicsilver/transport/inbound_stream.rb +53 -0
- data/lib/quicsilver/transport/stream.rb +43 -0
- data/lib/quicsilver/transport/stream_event.rb +49 -0
- data/lib/quicsilver/transport/stream_id.rb +26 -0
- data/lib/quicsilver/version.rb +3 -0
- data/lib/quicsilver.rb +96 -0
- data/lib/rackup/handler/quicsilver.rb +96 -0
- data/quicsilver.gemspec +66 -0
- metadata +362 -0
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Example 2: propagate one app-server load signal to a toy load balancer.
|
|
5
|
+
#
|
|
6
|
+
# bundle exec ruby examples/worker_load_balancing.rb
|
|
7
|
+
#
|
|
8
|
+
# This is the follow-up to lifecycle_drain.rb. Each worker exposes ordinary
|
|
9
|
+
# HTTP endpoints:
|
|
10
|
+
#
|
|
11
|
+
# /_quicsilver/ready
|
|
12
|
+
# /_quicsilver/stats
|
|
13
|
+
#
|
|
14
|
+
# The toy LB polls those endpoints and compares round-robin routing with a
|
|
15
|
+
# least-loaded policy. To make the signal visible, worker-1 is intentionally
|
|
16
|
+
# slower than the other workers.
|
|
17
|
+
|
|
18
|
+
require_relative "example_helper"
|
|
19
|
+
require "rbconfig"
|
|
20
|
+
|
|
21
|
+
$stdout.sync = true
|
|
22
|
+
|
|
23
|
+
HOST = "127.0.0.1"
|
|
24
|
+
WORKERS = Integer(ENV.fetch("WORKERS", "3"))
|
|
25
|
+
WORKER_THREADS = Integer(ENV.fetch("WORKER_THREADS", "2"))
|
|
26
|
+
MAX_QUEUE_SIZE = Integer(ENV.fetch("MAX_QUEUE_SIZE", (WORKER_THREADS * 4).to_s))
|
|
27
|
+
REQUESTS = Integer(ENV.fetch("REQUESTS", "36"))
|
|
28
|
+
CONCURRENCY = Integer(ENV.fetch("CONCURRENCY", "9"))
|
|
29
|
+
HOT_WORKER_MS = Integer(ENV.fetch("HOT_WORKER_MS", "250"))
|
|
30
|
+
UNREADY_PRESSURE = Float(ENV.fetch("UNREADY_PRESSURE", "0.85"))
|
|
31
|
+
|
|
32
|
+
class LoadSignal
|
|
33
|
+
def self.for(stats)
|
|
34
|
+
new(stats || {})
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def initialize(stats)
|
|
38
|
+
@stats = stats || {}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def ready?
|
|
42
|
+
running? && !draining? && !queue_full? && pressure < UNREADY_PRESSURE
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def accepting_requests?
|
|
46
|
+
ready?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def pressure(local_inflight: 0)
|
|
50
|
+
if @stats.empty?
|
|
51
|
+
1.0
|
|
52
|
+
else
|
|
53
|
+
[request_pressure(local_inflight: local_inflight), queue_pressure].max.clamp(0.0, 1.0)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def to_h
|
|
58
|
+
{
|
|
59
|
+
"ready" => ready?,
|
|
60
|
+
"accepting_requests" => accepting_requests?,
|
|
61
|
+
"pressure" => pressure.round(3),
|
|
62
|
+
"reason" => reason,
|
|
63
|
+
"components" => {
|
|
64
|
+
"requests" => request_pressure.round(3),
|
|
65
|
+
"queue" => queue_pressure.round(3)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
def running?
|
|
72
|
+
@stats["running"] == true
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def draining?
|
|
76
|
+
@stats["shutting_down"] == true
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def queue_full?
|
|
80
|
+
@stats.dig("scheduler", "full") == true
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def reason
|
|
84
|
+
if @stats.empty?
|
|
85
|
+
"stats_unavailable"
|
|
86
|
+
elsif !running?
|
|
87
|
+
"not_running"
|
|
88
|
+
elsif draining?
|
|
89
|
+
"draining"
|
|
90
|
+
elsif queue_full?
|
|
91
|
+
"queue_full"
|
|
92
|
+
elsif pressure >= UNREADY_PRESSURE
|
|
93
|
+
"high_pressure"
|
|
94
|
+
else
|
|
95
|
+
"ok"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def request_pressure(local_inflight: 0)
|
|
100
|
+
active = @stats.dig("requests", "active").to_f + local_inflight.to_f
|
|
101
|
+
threads = @stats.dig("scheduler", "threads").to_f
|
|
102
|
+
ratio(active, threads)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def queue_pressure
|
|
106
|
+
pending = @stats.dig("scheduler", "pending").to_f
|
|
107
|
+
max = @stats.dig("scheduler", "max_queue_size").to_f
|
|
108
|
+
ratio(pending, max)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def ratio(numerator, denominator)
|
|
112
|
+
denominator.positive? ? numerator / denominator : 0.0
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
Worker = Struct.new(:id, :host, :port, :pid, :delay_ms, keyword_init: true) do
|
|
117
|
+
def stats
|
|
118
|
+
response = Quicsilver::Client.get(host, port, "/_quicsilver/stats",
|
|
119
|
+
unsecure: true, request_timeout: 1, connection_timeout: 500)
|
|
120
|
+
JSON.parse(response.body)
|
|
121
|
+
rescue JSON::ParserError, Quicsilver::Error, SystemCallError
|
|
122
|
+
{}
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def ready?
|
|
126
|
+
response = Quicsilver::Client.get(host, port, "/_quicsilver/ready",
|
|
127
|
+
unsecure: true, request_timeout: 1, connection_timeout: 500)
|
|
128
|
+
response.status == 200
|
|
129
|
+
rescue Quicsilver::Error, SystemCallError
|
|
130
|
+
false
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def get(path)
|
|
134
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
135
|
+
response = Quicsilver::Client.get(host, port, path, unsecure: true, request_timeout: 5)
|
|
136
|
+
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round(1)
|
|
137
|
+
|
|
138
|
+
{ worker_id: id, status: response.status, duration_ms: duration_ms }
|
|
139
|
+
rescue => error
|
|
140
|
+
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round(1)
|
|
141
|
+
{ worker_id: id, status: nil, duration_ms: duration_ms, error: "#{error.class}: #{error.message}" }
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
class ToyLoadBalancer
|
|
146
|
+
def initialize(workers, strategy:)
|
|
147
|
+
@workers = workers
|
|
148
|
+
@strategy = strategy
|
|
149
|
+
@index = 0
|
|
150
|
+
@inflight = Hash.new(0)
|
|
151
|
+
@mutex = Mutex.new
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def get(path)
|
|
155
|
+
worker = choose_worker
|
|
156
|
+
track_start(worker)
|
|
157
|
+
worker.get(path)
|
|
158
|
+
ensure
|
|
159
|
+
track_finish(worker) if worker
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
private
|
|
163
|
+
def choose_worker
|
|
164
|
+
case @strategy
|
|
165
|
+
when :round_robin
|
|
166
|
+
round_robin_worker
|
|
167
|
+
when :least_loaded
|
|
168
|
+
least_loaded_worker
|
|
169
|
+
else
|
|
170
|
+
raise ArgumentError, "unknown strategy: #{@strategy.inspect}"
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def round_robin_worker
|
|
175
|
+
@mutex.synchronize do
|
|
176
|
+
worker = @workers[@index % @workers.size]
|
|
177
|
+
@index += 1
|
|
178
|
+
worker
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def least_loaded_worker
|
|
183
|
+
scored = @workers.map do |worker|
|
|
184
|
+
signal = LoadSignal.for(worker.stats)
|
|
185
|
+
[worker, signal.pressure(local_inflight: inflight_for(worker))]
|
|
186
|
+
end
|
|
187
|
+
lowest = scored.map(&:last).min
|
|
188
|
+
candidates = scored.select { |_, score| score == lowest }.map(&:first)
|
|
189
|
+
|
|
190
|
+
@mutex.synchronize do
|
|
191
|
+
worker = candidates[@index % candidates.size]
|
|
192
|
+
@index += 1
|
|
193
|
+
worker
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def track_start(worker)
|
|
198
|
+
@mutex.synchronize { @inflight[worker.id] += 1 }
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def track_finish(worker)
|
|
202
|
+
@mutex.synchronize { @inflight[worker.id] -= 1 }
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def inflight_for(worker)
|
|
206
|
+
@mutex.synchronize { @inflight[worker.id] }
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def worker_stats(server, worker_id, port, delay_ms, control_request: false)
|
|
211
|
+
stats = server.stats
|
|
212
|
+
|
|
213
|
+
if control_request
|
|
214
|
+
active = stats.dig("requests", "active").to_i
|
|
215
|
+
stats["requests"] = stats["requests"].merge("active" => [active - 1, 0].max)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
stats.merge(
|
|
219
|
+
"worker" => { "id" => worker_id, "pid" => Process.pid, "port" => port, "delay_ms" => delay_ms },
|
|
220
|
+
"load_signal" => LoadSignal.for(stats).to_h
|
|
221
|
+
)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def worker_signal(server, worker_id, port, delay_ms)
|
|
225
|
+
LoadSignal.for(worker_stats(server, worker_id, port, delay_ms, control_request: true))
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def run_worker!
|
|
229
|
+
Quicsilver.logger = Logger.new(File::NULL)
|
|
230
|
+
|
|
231
|
+
worker_id = ENV.fetch("WORKER_ID")
|
|
232
|
+
port = Integer(ENV.fetch("PORT"))
|
|
233
|
+
delay_ms = Integer(ENV.fetch("DELAY_MS", "0"))
|
|
234
|
+
server = nil
|
|
235
|
+
|
|
236
|
+
app = ->(env) {
|
|
237
|
+
signal = worker_signal(server, worker_id, port, delay_ms)
|
|
238
|
+
|
|
239
|
+
case env["PATH_INFO"]
|
|
240
|
+
when "/_quicsilver/ready"
|
|
241
|
+
Example.json_response(signal.ready? ? 200 : 503, signal.to_h.merge("worker_id" => worker_id))
|
|
242
|
+
when "/_quicsilver/stats"
|
|
243
|
+
Example.json_response(200, worker_stats(server, worker_id, port, delay_ms, control_request: true))
|
|
244
|
+
when "/hello"
|
|
245
|
+
if signal.accepting_requests?
|
|
246
|
+
sleep delay_ms / 1000.0 if delay_ms.positive?
|
|
247
|
+
Example.text_response(200, "hello from #{worker_id}\n")
|
|
248
|
+
else
|
|
249
|
+
Example.json_response(503, "error" => "worker overloaded", "load_signal" => signal.to_h)
|
|
250
|
+
end
|
|
251
|
+
else
|
|
252
|
+
Example.text_response(404, "not found\n")
|
|
253
|
+
end
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
server = Quicsilver::Server.new(
|
|
257
|
+
port,
|
|
258
|
+
app: app,
|
|
259
|
+
server_configuration: EXAMPLE_TLS_CONFIG,
|
|
260
|
+
threads: WORKER_THREADS,
|
|
261
|
+
max_queue_size: MAX_QUEUE_SIZE
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
trap("TERM") do
|
|
265
|
+
server.stop rescue nil
|
|
266
|
+
exit
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
server.start
|
|
270
|
+
ensure
|
|
271
|
+
server&.stop rescue nil
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def spawn_worker(index)
|
|
275
|
+
id = "worker-#{index + 1}"
|
|
276
|
+
delay_ms = index.zero? ? HOT_WORKER_MS : 0
|
|
277
|
+
port = Example.available_udp_port(HOST)
|
|
278
|
+
env = {
|
|
279
|
+
"QUICSILVER_TOY_LB_WORKER" => "1",
|
|
280
|
+
"WORKER_ID" => id,
|
|
281
|
+
"PORT" => port.to_s,
|
|
282
|
+
"DELAY_MS" => delay_ms.to_s,
|
|
283
|
+
"WORKER_THREADS" => WORKER_THREADS.to_s,
|
|
284
|
+
"MAX_QUEUE_SIZE" => MAX_QUEUE_SIZE.to_s,
|
|
285
|
+
"UNREADY_PRESSURE" => UNREADY_PRESSURE.to_s
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
pid = Process.spawn(env, RbConfig.ruby, __FILE__)
|
|
289
|
+
worker = Worker.new(id: id, host: HOST, port: port, pid: pid, delay_ms: delay_ms)
|
|
290
|
+
|
|
291
|
+
Example.wait_until { worker.ready? }
|
|
292
|
+
worker
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def run_phase(title, strategy, workers)
|
|
296
|
+
Example.heading title
|
|
297
|
+
|
|
298
|
+
lb = ToyLoadBalancer.new(workers, strategy: strategy)
|
|
299
|
+
queue = Queue.new
|
|
300
|
+
REQUESTS.times { queue << "/hello" }
|
|
301
|
+
|
|
302
|
+
results = []
|
|
303
|
+
results_mutex = Mutex.new
|
|
304
|
+
max_pressure = Hash.new(0.0)
|
|
305
|
+
monitor_done = false
|
|
306
|
+
|
|
307
|
+
monitor = Thread.new do
|
|
308
|
+
until monitor_done
|
|
309
|
+
workers.each do |worker|
|
|
310
|
+
pressure = worker.stats.dig("load_signal", "pressure").to_f
|
|
311
|
+
max_pressure[worker.id] = [max_pressure[worker.id], pressure].max
|
|
312
|
+
end
|
|
313
|
+
sleep 0.025
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
318
|
+
threads = CONCURRENCY.times.map do
|
|
319
|
+
Thread.new do
|
|
320
|
+
loop do
|
|
321
|
+
path = queue.pop(true) rescue nil
|
|
322
|
+
break unless path
|
|
323
|
+
|
|
324
|
+
result = lb.get(path)
|
|
325
|
+
results_mutex.synchronize { results << result }
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
threads.each(&:join)
|
|
330
|
+
elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round(1)
|
|
331
|
+
|
|
332
|
+
monitor_done = true
|
|
333
|
+
monitor.join
|
|
334
|
+
|
|
335
|
+
print_phase_summary(workers, results, max_pressure, elapsed_ms)
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def percentile(values, percentile)
|
|
339
|
+
return 0 if values.empty?
|
|
340
|
+
|
|
341
|
+
sorted = values.sort
|
|
342
|
+
index = ((percentile / 100.0) * (sorted.size - 1)).round
|
|
343
|
+
sorted[index]
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def print_phase_summary(workers, results, max_pressure, elapsed_ms)
|
|
347
|
+
durations = results.map { |result| result[:duration_ms] }
|
|
348
|
+
errors = results.count { |result| result[:error] || result[:status].to_i >= 500 }
|
|
349
|
+
|
|
350
|
+
Example.detail "elapsed", "#{elapsed_ms}ms"
|
|
351
|
+
Example.detail "latency", "p50=#{percentile(durations, 50)}ms p95=#{percentile(durations, 95)}ms"
|
|
352
|
+
Example.detail "errors", errors
|
|
353
|
+
|
|
354
|
+
puts
|
|
355
|
+
puts format(" %-8s %6s %8s %12s %9s %9s", "worker", "delay", "requests", "max_pressure", "p50", "p95")
|
|
356
|
+
puts " #{'-' * 66}"
|
|
357
|
+
|
|
358
|
+
request_counts = {}
|
|
359
|
+
|
|
360
|
+
workers.each do |worker|
|
|
361
|
+
worker_results = results.select { |result| result[:worker_id] == worker.id }
|
|
362
|
+
worker_durations = worker_results.map { |result| result[:duration_ms] }
|
|
363
|
+
request_counts[worker.id] = worker_results.size
|
|
364
|
+
|
|
365
|
+
puts format(
|
|
366
|
+
" %-8s %5dms %8d %12.2f %8.1fms %8.1fms",
|
|
367
|
+
worker.id,
|
|
368
|
+
worker.delay_ms,
|
|
369
|
+
worker_results.size,
|
|
370
|
+
max_pressure[worker.id],
|
|
371
|
+
percentile(worker_durations, 50),
|
|
372
|
+
percentile(worker_durations, 95)
|
|
373
|
+
)
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
{ p95: percentile(durations, 95), errors: errors, request_counts: request_counts }
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
if ENV["QUICSILVER_TOY_LB_WORKER"] == "1"
|
|
380
|
+
run_worker!
|
|
381
|
+
exit
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
workers = []
|
|
385
|
+
|
|
386
|
+
begin
|
|
387
|
+
Example.heading "Worker-aware Load Signal Demo", level: 1
|
|
388
|
+
Example.detail "scenario", "worker-1 is intentionally slow"
|
|
389
|
+
Example.detail "workers", WORKERS
|
|
390
|
+
Example.detail "worker threads", WORKER_THREADS
|
|
391
|
+
Example.detail "requests", REQUESTS
|
|
392
|
+
Example.detail "concurrency", CONCURRENCY
|
|
393
|
+
Example.detail "slow worker", "worker-1 sleeps #{HOT_WORKER_MS}ms per request"
|
|
394
|
+
Example.detail "signal flow", "Server#stats -> LoadSignal -> routing + readiness"
|
|
395
|
+
|
|
396
|
+
Example.heading "Starting workers"
|
|
397
|
+
workers = WORKERS.times.map { |index| spawn_worker(index) }
|
|
398
|
+
workers.each do |worker|
|
|
399
|
+
Example.detail worker.id, "https://#{worker.host}:#{worker.port} delay=#{worker.delay_ms}ms pid=#{worker.pid}"
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
round_robin = run_phase("1. Round robin", :round_robin, workers)
|
|
403
|
+
|
|
404
|
+
Quicsilver::Client.close_pool
|
|
405
|
+
sleep 0.2
|
|
406
|
+
|
|
407
|
+
least_loaded = run_phase("2. Least loaded", :least_loaded, workers)
|
|
408
|
+
|
|
409
|
+
slow_worker = workers.first.id
|
|
410
|
+
|
|
411
|
+
Example.heading "What to notice"
|
|
412
|
+
puts " Slow-worker requests: #{round_robin[:request_counts][slow_worker]} -> #{least_loaded[:request_counts][slow_worker]}"
|
|
413
|
+
puts " Fleet p95 latency: #{round_robin[:p95]}ms -> #{least_loaded[:p95]}ms"
|
|
414
|
+
puts " Errors: #{round_robin[:errors]} -> #{least_loaded[:errors]}"
|
|
415
|
+
puts
|
|
416
|
+
puts " This is pressure-aware routing, not outlier detection. It notices when an"
|
|
417
|
+
puts " endpoint is busy right now. A slow endpoint can still receive traffic when"
|
|
418
|
+
puts " it has spare request capacity."
|
|
419
|
+
ensure
|
|
420
|
+
Quicsilver::Client.close_pool rescue nil
|
|
421
|
+
workers.each do |worker|
|
|
422
|
+
Process.kill("TERM", worker.pid) rescue nil
|
|
423
|
+
Process.wait(worker.pid) rescue nil
|
|
424
|
+
end
|
|
425
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
require_relative "msquic_patch"
|
|
2
|
+
require 'mkmf'
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
|
|
5
|
+
ext_dir = File.expand_path('../../lib/quicsilver', __dir__)
|
|
6
|
+
gemspec_dir = File.expand_path('../..', __dir__)
|
|
7
|
+
|
|
8
|
+
# Skip compilation for installed platform gems that already contain a native
|
|
9
|
+
# extension. In a source checkout we must always compile for the current Ruby;
|
|
10
|
+
# otherwise a stale lib/quicsilver/quicsilver.bundle from another Ruby ABI can
|
|
11
|
+
# prevent rake-compiler from producing the extension it is about to copy.
|
|
12
|
+
source_checkout = File.exist?(File.expand_path("../../.git", __dir__))
|
|
13
|
+
precompiled_binary = File.exist?(File.join(ext_dir, "quicsilver.bundle")) ||
|
|
14
|
+
File.exist?(File.join(ext_dir, "quicsilver.so"))
|
|
15
|
+
|
|
16
|
+
if precompiled_binary && !source_checkout
|
|
17
|
+
File.write("Makefile", "install:\n\t@echo 'Using precompiled binary'\n\nall:\n\t@echo 'Using precompiled binary'\n")
|
|
18
|
+
exit
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# On macOS, use Apple clang if available. Homebrew clang can't find
|
|
22
|
+
# system headers and produces broken binaries with MsQuic.
|
|
23
|
+
if RUBY_PLATFORM =~ /darwin/ && File.exist?("/usr/bin/clang")
|
|
24
|
+
RbConfig::CONFIG["CC"] = "/usr/bin/clang"
|
|
25
|
+
RbConfig::MAKEFILE_CONFIG["CC"] = "/usr/bin/clang"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# --- Locate libmsquic ---
|
|
29
|
+
# Priority:
|
|
30
|
+
# 1. Vendored submodule build (vendor/msquic/build/bin/Release/)
|
|
31
|
+
# 2. Shipped dylib in lib/quicsilver/ (checked into git, always available)
|
|
32
|
+
# 3. Auto-build from submodule source
|
|
33
|
+
|
|
34
|
+
msquic_dir = File.expand_path('../../../vendor/msquic', __FILE__)
|
|
35
|
+
vendor_lib_dir = "#{msquic_dir}/build/bin/Release"
|
|
36
|
+
shipped_lib_dir = ext_dir # lib/quicsilver/ contains libmsquic.2.dylib
|
|
37
|
+
|
|
38
|
+
# A stale binary must not bypass the source patch when building from a checkout.
|
|
39
|
+
if File.exist?(File.join(msquic_dir, "CMakeLists.txt"))
|
|
40
|
+
MsquicPatch.apply!(msquic_dir)
|
|
41
|
+
end
|
|
42
|
+
needs_patched_build = File.exist?(File.join(msquic_dir, "CMakeLists.txt")) && !MsquicPatch.built?(msquic_dir)
|
|
43
|
+
|
|
44
|
+
# Find which directory has libmsquic
|
|
45
|
+
lib_dir = if !needs_patched_build && File.exist?(vendor_lib_dir) && Dir.glob("#{vendor_lib_dir}/libmsquic.*").any?
|
|
46
|
+
puts "Using vendored MsQuic from #{vendor_lib_dir}"
|
|
47
|
+
vendor_lib_dir
|
|
48
|
+
elsif !needs_patched_build && Dir.glob("#{shipped_lib_dir}/libmsquic*").any?
|
|
49
|
+
puts "Using shipped libmsquic from #{shipped_lib_dir}"
|
|
50
|
+
shipped_lib_dir
|
|
51
|
+
else
|
|
52
|
+
# Auto-build from submodule
|
|
53
|
+
puts "MsQuic not found, building from source..."
|
|
54
|
+
|
|
55
|
+
unless File.exist?(File.join(msquic_dir, 'CMakeLists.txt'))
|
|
56
|
+
Dir.chdir(gemspec_dir) do
|
|
57
|
+
system('git submodule update --init --recursive vendor/msquic') or
|
|
58
|
+
raise 'Failed to initialize MsQuic submodule'
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
MsquicPatch.apply!(msquic_dir)
|
|
63
|
+
|
|
64
|
+
cmake_args = ['-B build', '-DCMAKE_BUILD_TYPE=Release', '-DQUIC_TLS_LIB=quictls', '-DQUIC_LINUX_IOURING_ENABLED=OFF']
|
|
65
|
+
if RUBY_PLATFORM =~ /darwin/
|
|
66
|
+
cmake_args << '-DCMAKE_EXE_LINKER_FLAGS="-framework CoreServices"'
|
|
67
|
+
cmake_args << '-DCMAKE_SHARED_LINKER_FLAGS="-framework CoreServices"'
|
|
68
|
+
sdk_path = `xcrun --show-sdk-path 2>/dev/null`.strip
|
|
69
|
+
cmake_args << "-DCMAKE_OSX_SYSROOT=#{sdk_path}" unless sdk_path.empty?
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
env = { 'PATH' => "/usr/bin:#{ENV['PATH']}" }
|
|
73
|
+
Dir.chdir(msquic_dir) do
|
|
74
|
+
system(env, "cmake #{cmake_args.join(' ')}") or raise 'MsQuic cmake configure failed'
|
|
75
|
+
system(env, 'cmake --build build --config Release') or raise 'MsQuic build failed'
|
|
76
|
+
MsquicPatch.record_build!(msquic_dir)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
vendor_lib_dir
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Copy MsQuic next to the Ruby extension so @loader_path/@rpath works for
|
|
83
|
+
# Bundler git installs as well as local checkouts. GitHub checkouts do not have
|
|
84
|
+
# generated dylibs checked in, so when we build MsQuic from the submodule we
|
|
85
|
+
# must also place the runtime library where `require "quicsilver"` expects it.
|
|
86
|
+
FileUtils.mkdir_p(ext_dir)
|
|
87
|
+
Dir.glob(File.join(lib_dir, "libmsquic*")) do |library|
|
|
88
|
+
target = File.join(ext_dir, File.basename(library))
|
|
89
|
+
next if File.identical?(library, target) rescue false
|
|
90
|
+
|
|
91
|
+
FileUtils.cp(library, target)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# --- Locate MsQuic headers ---
|
|
95
|
+
# Available from vendored submodule or shipped in ext/quicsilver/include/
|
|
96
|
+
msquic_inc = "#{msquic_dir}/src/inc"
|
|
97
|
+
msquic_inc_public = "#{msquic_dir}/src/inc/public"
|
|
98
|
+
shipped_inc = File.join(File.dirname(__FILE__), 'include')
|
|
99
|
+
|
|
100
|
+
if File.exist?(msquic_inc)
|
|
101
|
+
$CFLAGS << " -I#{msquic_inc}"
|
|
102
|
+
$CFLAGS << " -I#{msquic_inc_public}"
|
|
103
|
+
elsif File.exist?(shipped_inc)
|
|
104
|
+
$CFLAGS << " -I#{shipped_inc}"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
$CFLAGS << " -I#{File.expand_path('.', __FILE__)}"
|
|
108
|
+
|
|
109
|
+
$LDFLAGS << " -L#{lib_dir}"
|
|
110
|
+
|
|
111
|
+
if RUBY_PLATFORM =~ /darwin/
|
|
112
|
+
$LDFLAGS << " -Wl,-rpath,@loader_path"
|
|
113
|
+
$LDFLAGS << " -Wl,-rpath,@loader_path/.."
|
|
114
|
+
else
|
|
115
|
+
$LDFLAGS << " -Wl,-rpath,\\$$ORIGIN"
|
|
116
|
+
$LDFLAGS << " -Wl,-rpath,\\$$ORIGIN/.."
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
unless find_library('msquic', nil, lib_dir)
|
|
120
|
+
raise "MSQUIC library not found in #{lib_dir}. " \
|
|
121
|
+
"Ensure lib/quicsilver/libmsquic.2.dylib exists or run 'rake build_msquic'."
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
create_makefile('quicsilver/quicsilver')
|