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,97 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
|
|
3
|
+
# gRPC-style request/response over HTTP/3.
|
|
4
|
+
#
|
|
5
|
+
# gRPC is just HTTP with:
|
|
6
|
+
# - content-type: application/grpc (or application/grpc+json)
|
|
7
|
+
# - 5-byte frame prefix: [compressed(1)][length(4)][message]
|
|
8
|
+
# - Status in trailers: grpc-status, grpc-message
|
|
9
|
+
#
|
|
10
|
+
# This example uses JSON for simplicity. In production you'd use
|
|
11
|
+
# protobuf (google-protobuf gem) for smaller, faster serialization.
|
|
12
|
+
# Quicsilver carries the bytes — the app chooses the encoding.
|
|
13
|
+
#
|
|
14
|
+
# ruby examples/grpc_style.rb
|
|
15
|
+
|
|
16
|
+
require_relative "example_helper"
|
|
17
|
+
require "json"
|
|
18
|
+
|
|
19
|
+
PORT = 4433
|
|
20
|
+
HOST = "localhost"
|
|
21
|
+
|
|
22
|
+
# gRPC frame: [0x00][4-byte big-endian length][message]
|
|
23
|
+
def grpc_encode(message)
|
|
24
|
+
data = message.to_json
|
|
25
|
+
[0, data.bytesize].pack("CN") + data
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def grpc_decode(frame)
|
|
29
|
+
_compressed, length = frame.unpack("CN")
|
|
30
|
+
JSON.parse(frame[5, length])
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
app = ->(env) {
|
|
34
|
+
path = env["PATH_INFO"]
|
|
35
|
+
body = env["rack.input"]&.read || ""
|
|
36
|
+
|
|
37
|
+
case path
|
|
38
|
+
when "/grpc.UserService/GetUser"
|
|
39
|
+
request = grpc_decode(body) rescue { "error" => "bad frame" }
|
|
40
|
+
user = { "id" => request["id"], "name" => "Alice", "email" => "alice@example.com" }
|
|
41
|
+
response_frame = grpc_encode(user)
|
|
42
|
+
|
|
43
|
+
[200,
|
|
44
|
+
{ "content-type" => "application/grpc+json" },
|
|
45
|
+
[response_frame]]
|
|
46
|
+
|
|
47
|
+
when "/grpc.UserService/ListUsers"
|
|
48
|
+
users = [
|
|
49
|
+
{ "id" => 1, "name" => "Alice" },
|
|
50
|
+
{ "id" => 2, "name" => "Bob" },
|
|
51
|
+
{ "id" => 3, "name" => "Charlie" }
|
|
52
|
+
]
|
|
53
|
+
response_frame = grpc_encode(users)
|
|
54
|
+
|
|
55
|
+
[200,
|
|
56
|
+
{ "content-type" => "application/grpc+json" },
|
|
57
|
+
[response_frame]]
|
|
58
|
+
|
|
59
|
+
else
|
|
60
|
+
[404, { "content-type" => "text/plain" }, ["Unknown gRPC method: #{path}"]]
|
|
61
|
+
end
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
server = Quicsilver::Server.new(PORT, app: app, server_configuration: EXAMPLE_TLS_CONFIG)
|
|
65
|
+
server_thread = Thread.new { server.start }
|
|
66
|
+
sleep 0.3
|
|
67
|
+
|
|
68
|
+
puts "🔌 gRPC-style over HTTP/3 (JSON, no protobuf)"
|
|
69
|
+
puts "=" * 50
|
|
70
|
+
|
|
71
|
+
client = Quicsilver::Client.new(HOST, PORT, unsecure: true)
|
|
72
|
+
|
|
73
|
+
# GetUser
|
|
74
|
+
puts "\n GetUser(id=1):"
|
|
75
|
+
request_frame = grpc_encode({ "id" => 1 })
|
|
76
|
+
response = client.post("/grpc.UserService/GetUser",
|
|
77
|
+
body: request_frame,
|
|
78
|
+
headers: { "content-type" => "application/grpc+json" })
|
|
79
|
+
user = grpc_decode(response.body)
|
|
80
|
+
puts " → #{user}"
|
|
81
|
+
|
|
82
|
+
# ListUsers
|
|
83
|
+
puts "\n ListUsers():"
|
|
84
|
+
request_frame = grpc_encode({})
|
|
85
|
+
response = client.post("/grpc.UserService/ListUsers",
|
|
86
|
+
body: request_frame,
|
|
87
|
+
headers: { "content-type" => "application/grpc+json" })
|
|
88
|
+
users = grpc_decode(response.body)
|
|
89
|
+
puts " → #{users}"
|
|
90
|
+
|
|
91
|
+
puts "\n gRPC is just HTTP + framing. Quicsilver carries the bytes."
|
|
92
|
+
puts " This example uses JSON — swap in protobuf for production."
|
|
93
|
+
|
|
94
|
+
client.disconnect
|
|
95
|
+
server.stop
|
|
96
|
+
server_thread.join(2)
|
|
97
|
+
puts "\n✅ Done"
|
|
@@ -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
|