quicsilver 0.4.0 → 0.5.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/.github/workflows/ci.yml +9 -1
- data/.github/workflows/cibuildgem.yaml +29 -5
- data/.gitignore +5 -0
- data/CHANGELOG.md +50 -0
- data/Gemfile.lock +8 -1
- data/README.md +128 -15
- data/Rakefile +10 -3
- data/benchmarks/baseline.rb +175 -0
- data/benchmarks/compare.rb +398 -0
- data/benchmarks/helpers.rb +138 -64
- data/benchmarks/latency.rb +128 -0
- data/benchmarks/loss.rb +193 -0
- data/benchmarks/mobile.rb +125 -0
- data/docs/connection_identity.md +141 -0
- data/examples/benchmark.rb +80 -0
- data/examples/connection_pool_demo.rb +1 -1
- data/examples/example_helper.rb +51 -0
- data/examples/feature_demo.rb +7 -7
- data/examples/grpc_style.rb +2 -2
- data/examples/latency_aware_routing.rb +382 -0
- data/examples/lifecycle_drain.rb +264 -0
- data/examples/priorities.rb +2 -2
- data/examples/rails_feature_test.rb +92 -72
- data/examples/server_stats.rb +117 -0
- data/examples/simple_client_test.rb +3 -3
- data/examples/trailers.rb +23 -23
- data/examples/webtransport_smoke.rb +546 -0
- data/examples/worker_load_balancing.rb +425 -0
- data/ext/quicsilver/extconf.rb +100 -15
- 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 +1640 -222
- data/lib/quicsilver/client/client.rb +419 -60
- data/lib/quicsilver/client/connection_pool.rb +122 -26
- data/lib/quicsilver/client/request.rb +106 -14
- data/lib/quicsilver/errors.rb +39 -0
- data/lib/quicsilver/libmsquic.2.dylib +0 -0
- data/lib/quicsilver/protocol/adapter.rb +41 -21
- 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 +12 -1
- data/lib/quicsilver/protocol/datagram.rb +40 -0
- data/lib/quicsilver/protocol/frame_parser.rb +5 -6
- data/lib/quicsilver/protocol/frame_reader.rb +31 -2
- data/lib/quicsilver/protocol/frames.rb +128 -12
- data/lib/quicsilver/protocol/priority.rb +9 -0
- data/lib/quicsilver/protocol/qpack/decoder.rb +4 -42
- data/lib/quicsilver/protocol/qpack/encoder.rb +10 -20
- data/lib/quicsilver/protocol/qpack/huffman.rb +4 -4
- data/lib/quicsilver/protocol/request_encoder.rb +6 -3
- data/lib/quicsilver/protocol/request_parser.rb +16 -2
- data/lib/quicsilver/protocol/response_encoder.rb +18 -8
- data/lib/quicsilver/protocol/response_parser.rb +6 -0
- data/lib/quicsilver/protocol/stream_input.rb +3 -3
- 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/rack_adapter.rb +77 -0
- data/lib/quicsilver/server/receive_queue.rb +106 -0
- data/lib/quicsilver/server/request_handler.rb +24 -13
- data/lib/quicsilver/server/request_registry.rb +18 -5
- 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 +655 -185
- 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 +103 -10
- data/lib/quicsilver/transport/connection.rb +302 -87
- data/lib/quicsilver/transport/connection_stats.rb +53 -0
- data/lib/quicsilver/transport/event_loop.rb +36 -0
- data/lib/quicsilver/transport/inbound_stream.rb +21 -1
- data/lib/quicsilver/transport/stream.rb +18 -3
- data/lib/quicsilver/transport/stream_event.rb +25 -2
- data/lib/quicsilver/transport/stream_id.rb +26 -0
- data/lib/quicsilver/version.rb +1 -1
- data/lib/quicsilver.rb +22 -11
- data/lib/rackup/handler/quicsilver.rb +57 -38
- data/quicsilver.gemspec +10 -3
- metadata +54 -8
- data/benchmarks/concurrent.rb +0 -110
- data/benchmarks/quicsilver_server.rb +0 -46
- data/benchmarks/rails.rb +0 -170
- data/benchmarks/throughput.rb +0 -113
- data/lib/quicsilver/quicsilver.bundle +0 -0
data/examples/example_helper.rb
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
# server = Quicsilver::Server.new(4433, app: app, server_configuration: EXAMPLE_TLS_CONFIG)
|
|
9
9
|
|
|
10
10
|
require "bundler/setup"
|
|
11
|
+
require "json"
|
|
12
|
+
require "socket"
|
|
13
|
+
require "thread"
|
|
11
14
|
require "quicsilver"
|
|
12
15
|
require "localhost/authority"
|
|
13
16
|
|
|
@@ -16,3 +19,51 @@ EXAMPLE_TLS_CONFIG = Quicsilver::Transport::Configuration.new(
|
|
|
16
19
|
authority.certificate_path,
|
|
17
20
|
authority.key_path
|
|
18
21
|
)
|
|
22
|
+
|
|
23
|
+
module Example
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
def wait_until(timeout: 5, interval: 0.01)
|
|
27
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
28
|
+
until yield
|
|
29
|
+
raise "timed out waiting" if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
30
|
+
|
|
31
|
+
sleep interval
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def available_udp_port(host = "127.0.0.1")
|
|
36
|
+
socket = UDPSocket.new
|
|
37
|
+
socket.bind(host, 0)
|
|
38
|
+
socket.addr[1]
|
|
39
|
+
ensure
|
|
40
|
+
socket&.close
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def json_response(status, body)
|
|
44
|
+
[status, { "content-type" => "application/json" }, [JSON.generate(body) << "\n"]]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def text_response(status, body)
|
|
48
|
+
[status, { "content-type" => "text/plain" }, [body]]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def say(line = "")
|
|
52
|
+
puts line
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def heading(text, level: 2)
|
|
56
|
+
say unless heading_count.zero?
|
|
57
|
+
say "#{'#' * level} #{text}"
|
|
58
|
+
@heading_count += 1
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def detail(label, value, width: 22)
|
|
62
|
+
say format(" %-#{width}s %s", label, value)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def heading_count
|
|
67
|
+
@heading_count ||= 0
|
|
68
|
+
end
|
|
69
|
+
end
|
data/examples/feature_demo.rb
CHANGED
|
@@ -62,7 +62,7 @@ puts "-" * 60
|
|
|
62
62
|
response = Quicsilver::Client.get(HOST, PORT, "/", unsecure: true)
|
|
63
63
|
elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t) * 1000).round(1)
|
|
64
64
|
label = i == 0 ? "← handshake" : "← reused"
|
|
65
|
-
puts " Request #{i}: #{response
|
|
65
|
+
puts " Request #{i}: #{response.status} — #{elapsed}ms #{label}"
|
|
66
66
|
end
|
|
67
67
|
|
|
68
68
|
# === 2. Multiple Endpoints ===
|
|
@@ -71,8 +71,8 @@ puts "-" * 60
|
|
|
71
71
|
|
|
72
72
|
["/", "/api/users", "/stream", "/nonexistent"].each do |path|
|
|
73
73
|
response = Quicsilver::Client.get(HOST, PORT, path, unsecure: true)
|
|
74
|
-
body_preview = response
|
|
75
|
-
puts " GET #{path} → #{response
|
|
74
|
+
body_preview = response.body[0..50].gsub("\n", "\\n")
|
|
75
|
+
puts " GET #{path} → #{response.status} | #{body_preview}"
|
|
76
76
|
end
|
|
77
77
|
|
|
78
78
|
# === 3. POST with Body ===
|
|
@@ -83,7 +83,7 @@ response = Quicsilver::Client.post(HOST, PORT, "/echo",
|
|
|
83
83
|
body: "Hello from quicsilver client!",
|
|
84
84
|
headers: { "content-type" => "text/plain" },
|
|
85
85
|
unsecure: true)
|
|
86
|
-
puts " POST /echo → #{response
|
|
86
|
+
puts " POST /echo → #{response.status} | #{response.body}"
|
|
87
87
|
|
|
88
88
|
# === 4. Concurrent Requests ===
|
|
89
89
|
puts "\n4️⃣ Concurrent Requests (multiplexing)"
|
|
@@ -95,7 +95,7 @@ t_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
|
95
95
|
t = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
96
96
|
response = client.get("/api/users")
|
|
97
97
|
elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t) * 1000).round(1)
|
|
98
|
-
puts " Request #{i}: #{response
|
|
98
|
+
puts " Request #{i}: #{response.status} — #{elapsed}ms"
|
|
99
99
|
end
|
|
100
100
|
total = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t_start) * 1000).round(1)
|
|
101
101
|
puts " All 10 completed in #{total}ms (single connection)"
|
|
@@ -108,8 +108,8 @@ puts "-" * 60
|
|
|
108
108
|
client = Quicsilver::Client.new(HOST, PORT, unsecure: true)
|
|
109
109
|
%i[get post put patch delete head].each do |method|
|
|
110
110
|
response = client.public_send(method, "/api/users")
|
|
111
|
-
body_size = response
|
|
112
|
-
puts " #{method.to_s.upcase.ljust(6)} /api/users → #{response
|
|
111
|
+
body_size = response.body&.bytesize || 0
|
|
112
|
+
puts " #{method.to_s.upcase.ljust(6)} /api/users → #{response.status} (#{body_size} bytes)"
|
|
113
113
|
end
|
|
114
114
|
client.disconnect
|
|
115
115
|
|
data/examples/grpc_style.rb
CHANGED
|
@@ -76,7 +76,7 @@ request_frame = grpc_encode({ "id" => 1 })
|
|
|
76
76
|
response = client.post("/grpc.UserService/GetUser",
|
|
77
77
|
body: request_frame,
|
|
78
78
|
headers: { "content-type" => "application/grpc+json" })
|
|
79
|
-
user = grpc_decode(response
|
|
79
|
+
user = grpc_decode(response.body)
|
|
80
80
|
puts " → #{user}"
|
|
81
81
|
|
|
82
82
|
# ListUsers
|
|
@@ -85,7 +85,7 @@ request_frame = grpc_encode({})
|
|
|
85
85
|
response = client.post("/grpc.UserService/ListUsers",
|
|
86
86
|
body: request_frame,
|
|
87
87
|
headers: { "content-type" => "application/grpc+json" })
|
|
88
|
-
users = grpc_decode(response
|
|
88
|
+
users = grpc_decode(response.body)
|
|
89
89
|
puts " → #{users}"
|
|
90
90
|
|
|
91
91
|
puts "\n gRPC is just HTTP + framing. Quicsilver carries the bytes."
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Example 3: latency-aware routing as a separate policy from pressure.
|
|
5
|
+
#
|
|
6
|
+
# bundle exec ruby examples/latency_aware_routing.rb
|
|
7
|
+
#
|
|
8
|
+
# worker-1 is intentionally slower, but it is not necessarily busy. A purely
|
|
9
|
+
# pressure-aware policy might still send it traffic when its queue is empty.
|
|
10
|
+
# This example tracks rolling request latency per worker and uses that history
|
|
11
|
+
# to reduce traffic to consistently slow endpoints.
|
|
12
|
+
|
|
13
|
+
require_relative "example_helper"
|
|
14
|
+
require "rbconfig"
|
|
15
|
+
|
|
16
|
+
$stdout.sync = true
|
|
17
|
+
|
|
18
|
+
HOST = "127.0.0.1"
|
|
19
|
+
WORKERS = Integer(ENV.fetch("WORKERS", "3"))
|
|
20
|
+
REQUESTS = Integer(ENV.fetch("REQUESTS", "24"))
|
|
21
|
+
CONCURRENCY = Integer(ENV.fetch("CONCURRENCY", "6"))
|
|
22
|
+
SLOW_WORKER_MS = Integer(ENV.fetch("SLOW_WORKER_MS", "250"))
|
|
23
|
+
WINDOW_SIZE = Integer(ENV.fetch("WINDOW_SIZE", "50"))
|
|
24
|
+
|
|
25
|
+
class RollingLatency
|
|
26
|
+
def initialize(size: WINDOW_SIZE)
|
|
27
|
+
@size = size
|
|
28
|
+
@samples = []
|
|
29
|
+
@mutex = Mutex.new
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def record(duration_ms, status)
|
|
33
|
+
@mutex.synchronize do
|
|
34
|
+
@samples << { duration_ms: duration_ms, status: status }
|
|
35
|
+
@samples.shift while @samples.size > @size
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def to_h
|
|
40
|
+
samples = @mutex.synchronize { @samples.dup }
|
|
41
|
+
durations = samples.map { |sample| sample[:duration_ms] }
|
|
42
|
+
errors = samples.count { |sample| sample[:status].to_i >= 500 }
|
|
43
|
+
|
|
44
|
+
{
|
|
45
|
+
"count" => samples.size,
|
|
46
|
+
"p50_ms" => percentile(durations, 50),
|
|
47
|
+
"p95_ms" => percentile(durations, 95),
|
|
48
|
+
"error_rate" => samples.empty? ? 0.0 : (errors.to_f / samples.size).round(3)
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def percentile(values, percentile)
|
|
55
|
+
return 0.0 if values.empty?
|
|
56
|
+
|
|
57
|
+
sorted = values.sort
|
|
58
|
+
index = ((percentile / 100.0) * (sorted.size - 1)).round
|
|
59
|
+
sorted[index].round(1)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
class LatencySignal
|
|
64
|
+
def self.for(stats)
|
|
65
|
+
new(stats || {})
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def initialize(stats)
|
|
69
|
+
@stats = stats || {}
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def score(local_inflight: 0)
|
|
73
|
+
p95_ms + (error_rate * 1_000) + (local_inflight * 50)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def to_h
|
|
77
|
+
{
|
|
78
|
+
"p95_ms" => p95_ms,
|
|
79
|
+
"error_rate" => error_rate,
|
|
80
|
+
"score" => score.round(1),
|
|
81
|
+
"reason" => reason
|
|
82
|
+
}
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def p95_ms
|
|
88
|
+
@stats.dig("latency", "p95_ms").to_f
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def error_rate
|
|
92
|
+
@stats.dig("latency", "error_rate").to_f
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def reason
|
|
96
|
+
if @stats.empty?
|
|
97
|
+
"stats_unavailable"
|
|
98
|
+
elsif error_rate.positive?
|
|
99
|
+
"errors"
|
|
100
|
+
elsif p95_ms > 200
|
|
101
|
+
"slow"
|
|
102
|
+
else
|
|
103
|
+
"ok"
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
Worker = Struct.new(:id, :host, :port, :pid, :delay_ms, keyword_init: true) do
|
|
109
|
+
def health
|
|
110
|
+
response = Quicsilver::Client.get(host, port, "/_quicsilver/health",
|
|
111
|
+
unsecure: true, request_timeout: 1, connection_timeout: 500)
|
|
112
|
+
JSON.parse(response.body)
|
|
113
|
+
rescue JSON::ParserError, Quicsilver::Error, SystemCallError
|
|
114
|
+
{}
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def ready?
|
|
118
|
+
response = Quicsilver::Client.get(host, port, "/_quicsilver/ready",
|
|
119
|
+
unsecure: true, request_timeout: 1, connection_timeout: 500)
|
|
120
|
+
response.status == 200
|
|
121
|
+
rescue Quicsilver::Error, SystemCallError
|
|
122
|
+
false
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def get(path)
|
|
126
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
127
|
+
response = Quicsilver::Client.get(host, port, path, unsecure: true, request_timeout: 5)
|
|
128
|
+
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round(1)
|
|
129
|
+
|
|
130
|
+
{ worker_id: id, status: response.status, duration_ms: duration_ms }
|
|
131
|
+
rescue => error
|
|
132
|
+
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round(1)
|
|
133
|
+
{ worker_id: id, status: nil, duration_ms: duration_ms, error: "#{error.class}: #{error.message}" }
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
class ToyLoadBalancer
|
|
138
|
+
def initialize(workers, strategy:)
|
|
139
|
+
@workers = workers
|
|
140
|
+
@strategy = strategy
|
|
141
|
+
@index = 0
|
|
142
|
+
@inflight = Hash.new(0)
|
|
143
|
+
@routed = Hash.new(0)
|
|
144
|
+
@mutex = Mutex.new
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def get(path)
|
|
148
|
+
worker = checkout_worker
|
|
149
|
+
worker.get(path)
|
|
150
|
+
ensure
|
|
151
|
+
checkin_worker(worker) if worker
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
private
|
|
155
|
+
|
|
156
|
+
def checkout_worker
|
|
157
|
+
@mutex.synchronize do
|
|
158
|
+
worker = choose_worker
|
|
159
|
+
@inflight[worker.id] += 1
|
|
160
|
+
@routed[worker.id] += 1
|
|
161
|
+
worker
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def choose_worker
|
|
166
|
+
case @strategy
|
|
167
|
+
when :round_robin
|
|
168
|
+
round_robin_worker
|
|
169
|
+
when :latency_aware
|
|
170
|
+
latency_aware_worker
|
|
171
|
+
else
|
|
172
|
+
raise ArgumentError, "unknown strategy: #{@strategy.inspect}"
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def round_robin_worker
|
|
177
|
+
worker = @workers[@index % @workers.size]
|
|
178
|
+
@index += 1
|
|
179
|
+
worker
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def latency_aware_worker
|
|
183
|
+
# Keep a tiny probe floor. Production outlier policies usually keep probing
|
|
184
|
+
# reduced-weight endpoints so they can recover instead of disappearing forever.
|
|
185
|
+
if (unprobed = @workers.find { |worker| @routed[worker.id].zero? })
|
|
186
|
+
return unprobed
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
scored = @workers.map do |worker|
|
|
190
|
+
signal = LatencySignal.for(worker.health)
|
|
191
|
+
[worker, signal.score(local_inflight: @inflight[worker.id])]
|
|
192
|
+
end
|
|
193
|
+
lowest = scored.map(&:last).min
|
|
194
|
+
scored.find { |_, score| score == lowest }.first
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def checkin_worker(worker)
|
|
198
|
+
@mutex.synchronize { @inflight[worker.id] -= 1 }
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def run_worker!
|
|
203
|
+
Quicsilver.logger = Logger.new(File::NULL)
|
|
204
|
+
|
|
205
|
+
worker_id = ENV.fetch("WORKER_ID")
|
|
206
|
+
port = Integer(ENV.fetch("PORT"))
|
|
207
|
+
delay_ms = Integer(ENV.fetch("DELAY_MS", "0"))
|
|
208
|
+
latency = RollingLatency.new
|
|
209
|
+
|
|
210
|
+
app = ->(env) {
|
|
211
|
+
case env["PATH_INFO"]
|
|
212
|
+
when "/_quicsilver/ready"
|
|
213
|
+
Example.json_response(200, "ready" => true, "worker_id" => worker_id)
|
|
214
|
+
when "/_quicsilver/health"
|
|
215
|
+
stats = latency.to_h
|
|
216
|
+
Example.json_response(200, stats.merge("latency" => stats, "signal" => LatencySignal.for("latency" => stats).to_h))
|
|
217
|
+
when "/hello"
|
|
218
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
219
|
+
status = 200
|
|
220
|
+
sleep delay_ms / 1000.0 if delay_ms.positive?
|
|
221
|
+
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round(1)
|
|
222
|
+
latency.record(duration_ms, status)
|
|
223
|
+
Example.text_response(status, "hello from #{worker_id}\n")
|
|
224
|
+
else
|
|
225
|
+
Example.text_response(404, "not found\n")
|
|
226
|
+
end
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
server = Quicsilver::Server.new(port, app: app, server_configuration: EXAMPLE_TLS_CONFIG)
|
|
230
|
+
|
|
231
|
+
trap("TERM") do
|
|
232
|
+
server.stop rescue nil
|
|
233
|
+
exit
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
server.start
|
|
237
|
+
ensure
|
|
238
|
+
server&.stop rescue nil
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def spawn_worker(index)
|
|
242
|
+
id = "worker-#{index + 1}"
|
|
243
|
+
delay_ms = index.zero? ? SLOW_WORKER_MS : 0
|
|
244
|
+
port = Example.available_udp_port(HOST)
|
|
245
|
+
env = {
|
|
246
|
+
"QUICSILVER_LATENCY_WORKER" => "1",
|
|
247
|
+
"WORKER_ID" => id,
|
|
248
|
+
"PORT" => port.to_s,
|
|
249
|
+
"DELAY_MS" => delay_ms.to_s
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
pid = Process.spawn(env, RbConfig.ruby, __FILE__)
|
|
253
|
+
worker = Worker.new(id: id, host: HOST, port: port, pid: pid, delay_ms: delay_ms)
|
|
254
|
+
|
|
255
|
+
Example.wait_until { worker.ready? }
|
|
256
|
+
worker
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def warm_up(workers)
|
|
260
|
+
Example.heading "Warm up latency history"
|
|
261
|
+
workers.each do |worker|
|
|
262
|
+
3.times { worker.get("/hello") }
|
|
263
|
+
health = worker.health
|
|
264
|
+
Example.detail worker.id, "delay=#{worker.delay_ms}ms p95=#{health.dig('latency', 'p95_ms')}ms"
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def run_phase(title, strategy, workers)
|
|
269
|
+
Example.heading title
|
|
270
|
+
|
|
271
|
+
lb = ToyLoadBalancer.new(workers, strategy: strategy)
|
|
272
|
+
queue = Queue.new
|
|
273
|
+
REQUESTS.times { queue << "/hello" }
|
|
274
|
+
|
|
275
|
+
results = []
|
|
276
|
+
results_mutex = Mutex.new
|
|
277
|
+
|
|
278
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
279
|
+
threads = CONCURRENCY.times.map do
|
|
280
|
+
Thread.new do
|
|
281
|
+
loop do
|
|
282
|
+
path = queue.pop(true) rescue nil
|
|
283
|
+
break unless path
|
|
284
|
+
|
|
285
|
+
result = lb.get(path)
|
|
286
|
+
results_mutex.synchronize { results << result }
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
threads.each(&:join)
|
|
291
|
+
elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round(1)
|
|
292
|
+
|
|
293
|
+
print_summary(workers, results, elapsed_ms)
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def percentile(values, percentile)
|
|
297
|
+
return 0 if values.empty?
|
|
298
|
+
|
|
299
|
+
sorted = values.sort
|
|
300
|
+
index = ((percentile / 100.0) * (sorted.size - 1)).round
|
|
301
|
+
sorted[index]
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def print_summary(workers, results, elapsed_ms)
|
|
305
|
+
durations = results.map { |result| result[:duration_ms] }
|
|
306
|
+
|
|
307
|
+
Example.detail "elapsed", "#{elapsed_ms}ms"
|
|
308
|
+
Example.detail "latency", "p50=#{percentile(durations, 50)}ms p95=#{percentile(durations, 95)}ms"
|
|
309
|
+
|
|
310
|
+
puts
|
|
311
|
+
puts format(" %-8s %6s %8s %9s %9s %10s", "worker", "delay", "requests", "p50", "p95", "health_p95")
|
|
312
|
+
puts " #{'-' * 67}"
|
|
313
|
+
|
|
314
|
+
request_counts = {}
|
|
315
|
+
workers.each do |worker|
|
|
316
|
+
worker_results = results.select { |result| result[:worker_id] == worker.id }
|
|
317
|
+
worker_durations = worker_results.map { |result| result[:duration_ms] }
|
|
318
|
+
health = worker.health
|
|
319
|
+
request_counts[worker.id] = worker_results.size
|
|
320
|
+
|
|
321
|
+
puts format(
|
|
322
|
+
" %-8s %5dms %8d %8.1fms %8.1fms %9.1fms",
|
|
323
|
+
worker.id,
|
|
324
|
+
worker.delay_ms,
|
|
325
|
+
worker_results.size,
|
|
326
|
+
percentile(worker_durations, 50),
|
|
327
|
+
percentile(worker_durations, 95),
|
|
328
|
+
health.dig("latency", "p95_ms").to_f
|
|
329
|
+
)
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
{ p95: percentile(durations, 95), request_counts: request_counts }
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
if ENV["QUICSILVER_LATENCY_WORKER"] == "1"
|
|
336
|
+
run_worker!
|
|
337
|
+
exit
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
workers = []
|
|
341
|
+
|
|
342
|
+
begin
|
|
343
|
+
Example.heading "Latency-aware Routing Demo", level: 1
|
|
344
|
+
Example.detail "scenario", "worker-1 is consistently slow"
|
|
345
|
+
Example.detail "workers", WORKERS
|
|
346
|
+
Example.detail "requests", REQUESTS
|
|
347
|
+
Example.detail "concurrency", CONCURRENCY
|
|
348
|
+
Example.detail "slow worker", "worker-1 sleeps #{SLOW_WORKER_MS}ms per request"
|
|
349
|
+
Example.detail "signal flow", "rolling latency -> LatencySignal -> routing"
|
|
350
|
+
|
|
351
|
+
Example.heading "Starting workers"
|
|
352
|
+
workers = WORKERS.times.map { |index| spawn_worker(index) }
|
|
353
|
+
workers.each do |worker|
|
|
354
|
+
Example.detail worker.id, "https://#{worker.host}:#{worker.port} delay=#{worker.delay_ms}ms pid=#{worker.pid}"
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
warm_up(workers)
|
|
358
|
+
|
|
359
|
+
round_robin = run_phase("1. Round robin", :round_robin, workers)
|
|
360
|
+
|
|
361
|
+
Quicsilver::Client.close_pool
|
|
362
|
+
sleep 0.2
|
|
363
|
+
|
|
364
|
+
latency_aware = run_phase("2. Latency aware", :latency_aware, workers)
|
|
365
|
+
|
|
366
|
+
slow_worker = workers.first.id
|
|
367
|
+
|
|
368
|
+
Example.heading "What to notice"
|
|
369
|
+
puts " Slow-worker requests: #{round_robin[:request_counts][slow_worker]} -> #{latency_aware[:request_counts][slow_worker]}"
|
|
370
|
+
puts " Fleet p95 latency: #{round_robin[:p95]}ms -> #{latency_aware[:p95]}ms"
|
|
371
|
+
puts
|
|
372
|
+
puts " This is latency-aware routing. It reduces traffic to an endpoint that is"
|
|
373
|
+
puts " historically slow, even when that endpoint is not currently full. The one"
|
|
374
|
+
puts " remaining request is a probe so the endpoint has a path to recovery."
|
|
375
|
+
puts " Combine this with pressure-aware routing when you want both capacity and health."
|
|
376
|
+
ensure
|
|
377
|
+
Quicsilver::Client.close_pool rescue nil
|
|
378
|
+
workers.each do |worker|
|
|
379
|
+
Process.kill("TERM", worker.pid) rescue nil
|
|
380
|
+
Process.wait(worker.pid) rescue nil
|
|
381
|
+
end
|
|
382
|
+
end
|