hyperion-rb 1.0.1 → 1.2.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/CHANGELOG.md +44 -0
- data/README.md +32 -1
- data/ext/hyperion_http/parser.c +141 -0
- data/lib/hyperion/adapter/rack.rb +14 -0
- data/lib/hyperion/admin_middleware.rb +110 -0
- data/lib/hyperion/cli.rb +82 -1
- data/lib/hyperion/config.rb +11 -1
- data/lib/hyperion/connection.rb +56 -4
- data/lib/hyperion/http2_handler.rb +243 -6
- data/lib/hyperion/logger.rb +94 -3
- data/lib/hyperion/master.rb +69 -1
- data/lib/hyperion/prometheus_exporter.rb +96 -0
- data/lib/hyperion/response_writer.rb +87 -10
- data/lib/hyperion/server.rb +106 -32
- data/lib/hyperion/thread_pool.rb +24 -8
- data/lib/hyperion/version.rb +1 -1
- data/lib/hyperion/worker.rb +19 -11
- data/lib/hyperion/worker_health.rb +33 -0
- data/lib/hyperion.rb +58 -0
- metadata +4 -1
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hyperion
|
|
4
|
+
# Renders Hyperion.stats as Prometheus text exposition format (v0.0.4).
|
|
5
|
+
# Mounted by AdminMiddleware on GET /-/metrics; the returned content-type
|
|
6
|
+
# is `text/plain; version=0.0.4; charset=utf-8`.
|
|
7
|
+
#
|
|
8
|
+
# Mapping rules:
|
|
9
|
+
# - keys listed in KNOWN_METRICS get their canonical name + curated HELP/TYPE
|
|
10
|
+
# - keys matching `responses_<3-digit>` are grouped under a single
|
|
11
|
+
# `hyperion_responses_status_total` family with a `status` label
|
|
12
|
+
# - any other key is auto-exported as `hyperion_<key>` with a generic HELP
|
|
13
|
+
# line, so newly-added counters surface in Prometheus without code changes
|
|
14
|
+
# here (the curated-name path is just nicer presentation, not gating)
|
|
15
|
+
#
|
|
16
|
+
# Output ordering is deterministic for stable scrape diffs:
|
|
17
|
+
# - known metrics in KNOWN_METRICS declaration order
|
|
18
|
+
# - status codes ascending
|
|
19
|
+
# - other keys alphabetically
|
|
20
|
+
module PrometheusExporter
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
KNOWN_METRICS = {
|
|
24
|
+
requests: { name: 'hyperion_requests_total',
|
|
25
|
+
help: 'Total HTTP requests handled',
|
|
26
|
+
type: 'counter' },
|
|
27
|
+
bytes_read: { name: 'hyperion_bytes_read_total',
|
|
28
|
+
help: 'Total bytes read from request sockets',
|
|
29
|
+
type: 'counter' },
|
|
30
|
+
bytes_written: { name: 'hyperion_bytes_written_total',
|
|
31
|
+
help: 'Total bytes written to response sockets',
|
|
32
|
+
type: 'counter' },
|
|
33
|
+
rejected_connections: { name: 'hyperion_rejected_connections_total',
|
|
34
|
+
help: 'Connections rejected due to backpressure (max_pending)',
|
|
35
|
+
type: 'counter' },
|
|
36
|
+
sendfile_responses: { name: 'hyperion_sendfile_responses_total',
|
|
37
|
+
help: 'Responses sent via plain-TCP sendfile(2) zero-copy path',
|
|
38
|
+
type: 'counter' },
|
|
39
|
+
tls_zerobuf_responses: { name: 'hyperion_tls_zerobuf_responses_total',
|
|
40
|
+
help: 'Responses sent via TLS IO.copy_stream (avoids userspace String build, but TLS encryption forces copy)',
|
|
41
|
+
type: 'counter' }
|
|
42
|
+
}.freeze
|
|
43
|
+
|
|
44
|
+
STATUS_KEY_PATTERN = /\Aresponses_(\d{3})\z/
|
|
45
|
+
|
|
46
|
+
STATUS_FAMILY_NAME = 'hyperion_responses_status_total'
|
|
47
|
+
STATUS_FAMILY_HELP = 'Responses by HTTP status code'
|
|
48
|
+
|
|
49
|
+
def render(stats)
|
|
50
|
+
buf = +''
|
|
51
|
+
grouped_status = {}
|
|
52
|
+
other = {}
|
|
53
|
+
known = {}
|
|
54
|
+
|
|
55
|
+
stats.each do |key, value|
|
|
56
|
+
if (match = key.to_s.match(STATUS_KEY_PATTERN))
|
|
57
|
+
grouped_status[match[1]] = value
|
|
58
|
+
elsif KNOWN_METRICS.key?(key)
|
|
59
|
+
known[key] = value
|
|
60
|
+
else
|
|
61
|
+
other[key] = value
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Known metrics first, in declaration order — gives the scrape a stable,
|
|
66
|
+
# human-friendly preamble regardless of hash insertion order.
|
|
67
|
+
KNOWN_METRICS.each do |key, meta|
|
|
68
|
+
next unless known.key?(key)
|
|
69
|
+
|
|
70
|
+
append_metric(buf, meta[:name], meta[:help], meta[:type], known[key])
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
unless grouped_status.empty?
|
|
74
|
+
buf << "# HELP #{STATUS_FAMILY_NAME} #{STATUS_FAMILY_HELP}\n"
|
|
75
|
+
buf << "# TYPE #{STATUS_FAMILY_NAME} counter\n"
|
|
76
|
+
grouped_status.sort.each do |status, value|
|
|
77
|
+
buf << %(#{STATUS_FAMILY_NAME}{status="#{status}"} #{value}\n)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
other.sort_by { |k, _| k.to_s }.each do |key, value|
|
|
82
|
+
name = "hyperion_#{key}"
|
|
83
|
+
append_metric(buf, name, 'Hyperion internal counter (auto-exported)', 'counter', value)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
buf
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def append_metric(buf, name, help, type, value)
|
|
90
|
+
buf << "# HELP #{name} #{help}\n"
|
|
91
|
+
buf << "# TYPE #{name} #{type}\n"
|
|
92
|
+
buf << "#{name} #{value}\n"
|
|
93
|
+
end
|
|
94
|
+
private_class_method :append_metric
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -36,6 +36,21 @@ module Hyperion
|
|
|
36
36
|
CRLF_HEADER_VALUE = /[\r\n]/
|
|
37
37
|
|
|
38
38
|
def write(io, status, headers, body, keep_alive: false)
|
|
39
|
+
# Zero-copy fast path: bodies that point at an on-disk file (Rack::Files,
|
|
40
|
+
# asset servers, signed-download responders) get streamed via
|
|
41
|
+
# IO.copy_stream which delegates to sendfile(2) on Linux for plain TCP
|
|
42
|
+
# sockets — bytes go from the file's page cache straight to the socket
|
|
43
|
+
# buffer with no userspace allocation. For TLS sockets we still avoid the
|
|
44
|
+
# multi-MB String build, but encryption forces a userspace round-trip so
|
|
45
|
+
# we count that path separately.
|
|
46
|
+
return write_sendfile(io, status, headers, body, keep_alive: keep_alive) if body.respond_to?(:to_path)
|
|
47
|
+
|
|
48
|
+
write_buffered(io, status, headers, body, keep_alive: keep_alive)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def write_buffered(io, status, headers, body, keep_alive:)
|
|
39
54
|
# Phase 1 buffers the full body so Content-Length is exact.
|
|
40
55
|
# Phase 2 introduces chunked transfer-encoding for streaming bodies;
|
|
41
56
|
# Phase 5 batches via IO::Buffer to avoid this intermediate String.
|
|
@@ -43,7 +58,7 @@ module Hyperion
|
|
|
43
58
|
body.each { |chunk| buffered << chunk }
|
|
44
59
|
|
|
45
60
|
reason = REASONS[status] || 'Unknown'
|
|
46
|
-
date_str =
|
|
61
|
+
date_str = cached_date
|
|
47
62
|
|
|
48
63
|
head = build_head(status, reason, headers, buffered.bytesize, keep_alive, date_str)
|
|
49
64
|
|
|
@@ -51,19 +66,68 @@ module Hyperion
|
|
|
51
66
|
# SINGLE io.write call. Each syscall round-trip is ~1 usec on macOS
|
|
52
67
|
# kqueue; before this change we issued (1 status) + (N headers) + (1 blank)
|
|
53
68
|
# + (1 body) = 8+ syscalls per response. Now: 1 syscall.
|
|
54
|
-
if buffered.empty?
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
69
|
+
bytes_out = if buffered.empty?
|
|
70
|
+
io.write(head)
|
|
71
|
+
head.bytesize
|
|
72
|
+
else
|
|
73
|
+
# Concatenate into the head buffer (which is already a fresh +''
|
|
74
|
+
# from the C builder or the Ruby fallback) so we still emit a
|
|
75
|
+
# single write.
|
|
76
|
+
head << buffered
|
|
77
|
+
io.write(head)
|
|
78
|
+
head.bytesize
|
|
79
|
+
end
|
|
80
|
+
Hyperion.metrics.increment(:bytes_written, bytes_out)
|
|
62
81
|
ensure
|
|
63
82
|
body.close if body.respond_to?(:close)
|
|
64
83
|
end
|
|
65
84
|
|
|
66
|
-
|
|
85
|
+
def write_sendfile(io, status, headers, body, keep_alive:)
|
|
86
|
+
path = body.to_path
|
|
87
|
+
file = File.open(path, 'rb')
|
|
88
|
+
file_size = file.size
|
|
89
|
+
|
|
90
|
+
# If the app explicitly set content-length, respect it; otherwise use the
|
|
91
|
+
# real file size. Rack::Files does not pre-set content-length, so the
|
|
92
|
+
# common case is the File.size branch.
|
|
93
|
+
content_length = explicit_content_length(headers) || file_size
|
|
94
|
+
|
|
95
|
+
reason = REASONS[status] || 'Unknown'
|
|
96
|
+
date_str = cached_date
|
|
97
|
+
head = build_head(status, reason, headers, content_length, keep_alive, date_str)
|
|
98
|
+
|
|
99
|
+
io.write(head)
|
|
100
|
+
# IO.copy_stream copies up to file_size bytes from the file to the socket.
|
|
101
|
+
# On Linux + plain TCPSocket this triggers sendfile(2) — kernel-level
|
|
102
|
+
# zero-copy. On TLS sockets and non-Linux platforms it falls back to
|
|
103
|
+
# internal read+write loops, but we still avoid building a String the
|
|
104
|
+
# size of the file in Ruby.
|
|
105
|
+
copied = IO.copy_stream(file, io, file_size)
|
|
106
|
+
|
|
107
|
+
record_zero_copy_metric(io)
|
|
108
|
+
Hyperion.metrics.increment(:bytes_written, head.bytesize + copied)
|
|
109
|
+
ensure
|
|
110
|
+
file&.close
|
|
111
|
+
body.close if body.respond_to?(:close)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def explicit_content_length(headers)
|
|
115
|
+
headers.each do |k, v|
|
|
116
|
+
return v.to_i if k.to_s.casecmp('content-length').zero?
|
|
117
|
+
end
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Plain TCPSocket → real sendfile(2). TLS-wrapped sockets cannot use
|
|
122
|
+
# sendfile (kernel can't encrypt) but still avoid the per-response String
|
|
123
|
+
# allocation, so we track them under a separate counter.
|
|
124
|
+
def record_zero_copy_metric(io)
|
|
125
|
+
if defined?(::OpenSSL::SSL::SSLSocket) && io.is_a?(::OpenSSL::SSL::SSLSocket)
|
|
126
|
+
Hyperion.metrics.increment(:tls_zerobuf_responses)
|
|
127
|
+
else
|
|
128
|
+
Hyperion.metrics.increment(:sendfile_responses)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
67
131
|
|
|
68
132
|
# rc17: prefer the C extension when available — eliminates the per-response
|
|
69
133
|
# status-line interpolation, normalized hash, and per-header String#<<
|
|
@@ -76,6 +140,19 @@ module Hyperion
|
|
|
76
140
|
end
|
|
77
141
|
end
|
|
78
142
|
|
|
143
|
+
# Cached HTTP `Date:` header at second resolution. `Time.now.httpdate`
|
|
144
|
+
# allocates several strings; at high r/s the cache reuses one String per
|
|
145
|
+
# second per thread instead of allocating per response.
|
|
146
|
+
def cached_date
|
|
147
|
+
now_s = Process.clock_gettime(Process::CLOCK_REALTIME, :second)
|
|
148
|
+
cache = (Thread.current[:__hyperion_date_cache__] ||= [-1, ''])
|
|
149
|
+
return cache[1] if cache[0] == now_s
|
|
150
|
+
|
|
151
|
+
cache[0] = now_s
|
|
152
|
+
cache[1] = Time.now.httpdate
|
|
153
|
+
cache[1]
|
|
154
|
+
end
|
|
155
|
+
|
|
79
156
|
def build_head_ruby(status, reason, headers, body_size, keep_alive, date_str)
|
|
80
157
|
normalized = {}
|
|
81
158
|
headers.each { |k, v| normalized[k.to_s.downcase] = v }
|
data/lib/hyperion/server.rb
CHANGED
|
@@ -20,18 +20,40 @@ module Hyperion
|
|
|
20
20
|
DEFAULT_READ_TIMEOUT_SECONDS = 30
|
|
21
21
|
DEFAULT_THREAD_COUNT = 5
|
|
22
22
|
|
|
23
|
+
# Pre-built minimal 503 response for the backpressure path. We bypass
|
|
24
|
+
# ResponseWriter / Rack entirely — no env build, no app dispatch, no
|
|
25
|
+
# access-log line. The bytes are frozen and reused across every
|
|
26
|
+
# rejection so the overload path stays allocation-free. Body is JSON
|
|
27
|
+
# so JSON-only API consumers don't have to special-case the format.
|
|
28
|
+
REJECT_503 = lambda {
|
|
29
|
+
body = +%({"error":"server_busy","retry_after_seconds":1}\n)
|
|
30
|
+
body.force_encoding(Encoding::ASCII_8BIT)
|
|
31
|
+
head = +"HTTP/1.1 503 Service Unavailable\r\n" \
|
|
32
|
+
"content-type: application/json\r\n" \
|
|
33
|
+
"content-length: #{body.bytesize}\r\n" \
|
|
34
|
+
"retry-after: 1\r\n" \
|
|
35
|
+
"connection: close\r\n" \
|
|
36
|
+
"\r\n"
|
|
37
|
+
head.force_encoding(Encoding::ASCII_8BIT)
|
|
38
|
+
(head + body).freeze
|
|
39
|
+
}.call
|
|
40
|
+
|
|
23
41
|
attr_reader :host, :port
|
|
24
42
|
|
|
25
43
|
def initialize(app:, host: '127.0.0.1', port: 9292, read_timeout: DEFAULT_READ_TIMEOUT_SECONDS,
|
|
26
|
-
tls: nil, thread_count: DEFAULT_THREAD_COUNT
|
|
27
|
-
|
|
28
|
-
@
|
|
29
|
-
@
|
|
30
|
-
@
|
|
31
|
-
@
|
|
32
|
-
@
|
|
33
|
-
@
|
|
34
|
-
@
|
|
44
|
+
tls: nil, thread_count: DEFAULT_THREAD_COUNT, max_pending: nil,
|
|
45
|
+
max_request_read_seconds: 60, h2_settings: nil)
|
|
46
|
+
@host = host
|
|
47
|
+
@port = port
|
|
48
|
+
@app = app
|
|
49
|
+
@read_timeout = read_timeout
|
|
50
|
+
@tls = tls
|
|
51
|
+
@thread_count = thread_count
|
|
52
|
+
@max_pending = max_pending
|
|
53
|
+
@max_request_read_seconds = max_request_read_seconds
|
|
54
|
+
@h2_settings = h2_settings
|
|
55
|
+
@thread_pool = nil
|
|
56
|
+
@stopped = false
|
|
35
57
|
end
|
|
36
58
|
|
|
37
59
|
def listen
|
|
@@ -83,26 +105,19 @@ module Hyperion
|
|
|
83
105
|
|
|
84
106
|
def start
|
|
85
107
|
listen unless @server
|
|
86
|
-
@thread_pool = ThreadPool.new(size: @thread_count) if @thread_count.positive?
|
|
108
|
+
@thread_pool = ThreadPool.new(size: @thread_count, max_pending: @max_pending) if @thread_count.positive?
|
|
87
109
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
# own fiber inside Http2Handler.
|
|
100
|
-
if @thread_pool && !@tls
|
|
101
|
-
@thread_pool.submit_connection(socket, @app)
|
|
102
|
-
else
|
|
103
|
-
task.async { dispatch(socket) }
|
|
104
|
-
end
|
|
105
|
-
end
|
|
110
|
+
if @tls
|
|
111
|
+
# TLS path: ALPN may pick `h2`, and h2 spawns one fiber per stream
|
|
112
|
+
# inside Http2Handler. Keep the Async wrapper so the scheduler is
|
|
113
|
+
# available for those fibers and for handshake yields.
|
|
114
|
+
start_async_loop
|
|
115
|
+
else
|
|
116
|
+
# Plain HTTP/1.1: the worker thread owns each connection for its
|
|
117
|
+
# lifetime, so the Async wrapper adds zero value (no fibers ever
|
|
118
|
+
# run on this loop's task). Skip it — pure IO.select + accept_nonblock
|
|
119
|
+
# shaves measurable overhead off the accept hot path.
|
|
120
|
+
start_raw_loop
|
|
106
121
|
end
|
|
107
122
|
ensure
|
|
108
123
|
@thread_pool&.shutdown
|
|
@@ -117,20 +132,79 @@ module Hyperion
|
|
|
117
132
|
|
|
118
133
|
private
|
|
119
134
|
|
|
135
|
+
# Plain HTTP/1.1 accept loop — no fiber wrap. Connections go straight to
|
|
136
|
+
# a worker via the thread pool, or are served inline when no pool is
|
|
137
|
+
# configured (thread_count: 0). Matches the dispatch contract used by
|
|
138
|
+
# the TLS path; just skips the irrelevant h2/ALPN branch.
|
|
139
|
+
def start_raw_loop
|
|
140
|
+
until @stopped
|
|
141
|
+
socket = accept_or_nil
|
|
142
|
+
next unless socket
|
|
143
|
+
|
|
144
|
+
apply_timeout(socket)
|
|
145
|
+
if @thread_pool
|
|
146
|
+
unless @thread_pool.submit_connection(socket, @app,
|
|
147
|
+
max_request_read_seconds: @max_request_read_seconds)
|
|
148
|
+
reject_connection(socket)
|
|
149
|
+
end
|
|
150
|
+
else
|
|
151
|
+
Connection.new.serve(socket, @app, max_request_read_seconds: @max_request_read_seconds)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# TLS / h2-capable accept loop. The Async wrapper is required because
|
|
157
|
+
# h2 streams (inside Http2Handler) and the ALPN handshake yield
|
|
158
|
+
# cooperatively via the scheduler.
|
|
159
|
+
def start_async_loop
|
|
160
|
+
Async do |task|
|
|
161
|
+
until @stopped
|
|
162
|
+
socket = accept_or_nil
|
|
163
|
+
next unless socket
|
|
164
|
+
|
|
165
|
+
apply_timeout(socket)
|
|
166
|
+
task.async { dispatch(socket) }
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
120
171
|
def dispatch(socket)
|
|
121
172
|
if socket.is_a?(::OpenSSL::SSL::SSLSocket) && socket.alpn_protocol == 'h2'
|
|
122
173
|
# HTTP/2: each stream runs on a fiber inside Http2Handler. The
|
|
123
174
|
# handler still uses the pool's `#call` for app.call hops on each
|
|
124
175
|
# stream (one per stream, not one per connection).
|
|
125
|
-
Http2Handler.new(app: @app, thread_pool: @thread_pool).serve(socket)
|
|
176
|
+
Http2Handler.new(app: @app, thread_pool: @thread_pool, h2_settings: @h2_settings).serve(socket)
|
|
126
177
|
elsif @thread_pool
|
|
127
178
|
# HTTP/1.1 (e.g. TLS-wrapped after ALPN picked http/1.1): hand the
|
|
128
179
|
# connection to a worker thread. The fiber that called dispatch
|
|
129
|
-
# returns immediately.
|
|
130
|
-
@thread_pool.submit_connection(socket, @app
|
|
180
|
+
# returns immediately. On overflow, reject with 503 + close.
|
|
181
|
+
unless @thread_pool.submit_connection(socket, @app,
|
|
182
|
+
max_request_read_seconds: @max_request_read_seconds)
|
|
183
|
+
reject_connection(socket)
|
|
184
|
+
end
|
|
131
185
|
else
|
|
132
186
|
# No pool (thread_count: 0): inline on the calling fiber.
|
|
133
|
-
Connection.new.serve(socket, @app)
|
|
187
|
+
Connection.new.serve(socket, @app, max_request_read_seconds: @max_request_read_seconds)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Backpressure rejection. Emits a pre-built 503 + closes the socket.
|
|
192
|
+
# No Rack env, no app dispatch, no access-log line — the overload
|
|
193
|
+
# path must stay cheap so we don't pile rejection cost on top of the
|
|
194
|
+
# already-saturated workers. Bumps :rejected_connections so operators
|
|
195
|
+
# can alert on sustained overload.
|
|
196
|
+
def reject_connection(socket)
|
|
197
|
+
socket.write(REJECT_503)
|
|
198
|
+
Hyperion.metrics.increment(:rejected_connections)
|
|
199
|
+
rescue StandardError
|
|
200
|
+
# Client may have hung up between accept and our 503 write — that's
|
|
201
|
+
# the failure mode we're protecting them from anyway, so swallow.
|
|
202
|
+
nil
|
|
203
|
+
ensure
|
|
204
|
+
begin
|
|
205
|
+
socket.close
|
|
206
|
+
rescue StandardError
|
|
207
|
+
nil
|
|
134
208
|
end
|
|
135
209
|
end
|
|
136
210
|
|
data/lib/hyperion/thread_pool.rb
CHANGED
|
@@ -26,11 +26,12 @@ module Hyperion
|
|
|
26
26
|
class ThreadPool
|
|
27
27
|
SHUTDOWN = :__hyperion_thread_pool_shutdown__
|
|
28
28
|
|
|
29
|
-
attr_reader :size
|
|
29
|
+
attr_reader :size, :max_pending
|
|
30
30
|
|
|
31
|
-
def initialize(size:)
|
|
32
|
-
@size
|
|
33
|
-
@
|
|
31
|
+
def initialize(size:, max_pending: nil)
|
|
32
|
+
@size = size
|
|
33
|
+
@max_pending = max_pending
|
|
34
|
+
@inbox = Queue.new # multiplexes both kinds of jobs
|
|
34
35
|
# Pre-allocate one reply queue per in-flight slot for the legacy `#call`
|
|
35
36
|
# path. Bounded by `size`: if all workers are busy, all reply queues are
|
|
36
37
|
# checked out, and the next caller blocks on `@reply_pool.pop` until a
|
|
@@ -43,8 +44,23 @@ module Hyperion
|
|
|
43
44
|
# HTTP/1.1 path: hand the whole socket to a worker thread. The worker
|
|
44
45
|
# runs `Connection#serve(socket, app)` directly. No per-request hop.
|
|
45
46
|
# Returns immediately — caller does not wait.
|
|
46
|
-
|
|
47
|
-
|
|
47
|
+
#
|
|
48
|
+
# Returns true on enqueue, false on rejection. When `max_pending` is set
|
|
49
|
+
# and the inbox already has at least that many entries, the connection
|
|
50
|
+
# is rejected up to the caller (Server emits a 503 and closes the
|
|
51
|
+
# socket). Without `max_pending` (default nil) the queue is unbounded
|
|
52
|
+
# and we always return true — preserves pre-1.2 behaviour.
|
|
53
|
+
#
|
|
54
|
+
# The check is inherently racy with worker drain — workers may pop
|
|
55
|
+
# between our `size` read and the `<<`. Backpressure is statistical,
|
|
56
|
+
# not strict. Off-by-one over the configured cap during a thundering
|
|
57
|
+
# accept burst is acceptable; the cost of stricter sync would be a
|
|
58
|
+
# mutex on every enqueue, which we won't pay on the hot path.
|
|
59
|
+
def submit_connection(socket, app, max_request_read_seconds: 60)
|
|
60
|
+
return false if @max_pending && @inbox.size >= @max_pending
|
|
61
|
+
|
|
62
|
+
@inbox << [:connection, socket, app, max_request_read_seconds]
|
|
63
|
+
true
|
|
48
64
|
end
|
|
49
65
|
|
|
50
66
|
# HTTP/2 + sub-call path: hop one `app.call` from the calling fiber to a
|
|
@@ -78,12 +94,12 @@ module Hyperion
|
|
|
78
94
|
|
|
79
95
|
case job[0]
|
|
80
96
|
when :connection
|
|
81
|
-
_, socket, app = job
|
|
97
|
+
_, socket, app, max_request_read_seconds = job
|
|
82
98
|
# Worker thread owns the connection for its full lifetime. Pass
|
|
83
99
|
# thread_pool: nil so Connection#call_app inlines Adapter::Rack.call
|
|
84
100
|
# — the worker IS the pool, no further hop required.
|
|
85
101
|
begin
|
|
86
|
-
Hyperion::Connection.new.serve(socket, app)
|
|
102
|
+
Hyperion::Connection.new.serve(socket, app, max_request_read_seconds: max_request_read_seconds)
|
|
87
103
|
rescue StandardError => e
|
|
88
104
|
Hyperion.logger.error do
|
|
89
105
|
{
|
data/lib/hyperion/version.rb
CHANGED
data/lib/hyperion/worker.rb
CHANGED
|
@@ -18,16 +18,21 @@ module Hyperion
|
|
|
18
18
|
class Worker
|
|
19
19
|
def initialize(host:, port:, app:, read_timeout:, tls: nil,
|
|
20
20
|
thread_count: Server::DEFAULT_THREAD_COUNT,
|
|
21
|
-
config: nil, worker_index: 0, listener: nil
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
@
|
|
25
|
-
@
|
|
26
|
-
@
|
|
27
|
-
@
|
|
28
|
-
@
|
|
29
|
-
@
|
|
30
|
-
@
|
|
21
|
+
config: nil, worker_index: 0, listener: nil,
|
|
22
|
+
max_pending: nil, max_request_read_seconds: 60,
|
|
23
|
+
h2_settings: nil)
|
|
24
|
+
@host = host
|
|
25
|
+
@port = port
|
|
26
|
+
@app = app
|
|
27
|
+
@read_timeout = read_timeout
|
|
28
|
+
@tls = tls
|
|
29
|
+
@thread_count = thread_count
|
|
30
|
+
@config = config || Hyperion::Config.new
|
|
31
|
+
@worker_index = worker_index
|
|
32
|
+
@listener = listener
|
|
33
|
+
@max_pending = max_pending
|
|
34
|
+
@max_request_read_seconds = max_request_read_seconds
|
|
35
|
+
@h2_settings = h2_settings
|
|
31
36
|
end
|
|
32
37
|
|
|
33
38
|
def run
|
|
@@ -43,7 +48,10 @@ module Hyperion
|
|
|
43
48
|
|
|
44
49
|
server = Server.new(host: @host, port: @port, app: @app,
|
|
45
50
|
read_timeout: @read_timeout, tls: @tls,
|
|
46
|
-
thread_count: @thread_count
|
|
51
|
+
thread_count: @thread_count,
|
|
52
|
+
max_pending: @max_pending,
|
|
53
|
+
max_request_read_seconds: @max_request_read_seconds,
|
|
54
|
+
h2_settings: @h2_settings)
|
|
47
55
|
tcp_server = @listener || build_reuseport_listener
|
|
48
56
|
server.adopt_listener(tcp_server)
|
|
49
57
|
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hyperion
|
|
4
|
+
# Measures a worker process's resident set size (RSS) in MiB.
|
|
5
|
+
# Cross-platform: uses /proc/<pid>/statm on Linux (zero subprocess) and
|
|
6
|
+
# `ps -o rss= -p <pid>` everywhere else (macOS, BSD).
|
|
7
|
+
module WorkerHealth
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# Returns the worker's RSS in MiB, or nil if it can't be read (process
|
|
11
|
+
# gone, ps not available, /proc not mounted). Callers must handle nil
|
|
12
|
+
# gracefully — health checks must never crash the supervisor.
|
|
13
|
+
def rss_mb(pid)
|
|
14
|
+
if File.readable?("/proc/#{pid}/statm")
|
|
15
|
+
# statm fields are in pages; column index 1 is "resident".
|
|
16
|
+
# PAGE_SIZE = 4096 on x86_64 / aarch64 Linux.
|
|
17
|
+
contents = File.read("/proc/#{pid}/statm")
|
|
18
|
+
pages = contents.split.fetch(1).to_i
|
|
19
|
+
bytes = pages * 4096
|
|
20
|
+
bytes / 1024 / 1024
|
|
21
|
+
else
|
|
22
|
+
# Fallback: ps emits RSS in KiB.
|
|
23
|
+
out = `ps -o rss= -p #{pid} 2>/dev/null`
|
|
24
|
+
kib = out.strip.to_i
|
|
25
|
+
return nil if kib.zero?
|
|
26
|
+
|
|
27
|
+
kib / 1024
|
|
28
|
+
end
|
|
29
|
+
rescue StandardError
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
data/lib/hyperion.rb
CHANGED
|
@@ -25,6 +25,23 @@ module Hyperion
|
|
|
25
25
|
metrics.snapshot
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
+
# Whether YJIT is currently enabled in this Ruby process. False on Rubies
|
|
29
|
+
# that don't ship YJIT (JRuby, TruffleRuby) and on CRuby builds compiled
|
|
30
|
+
# without YJIT support. Cheap (no allocations) — safe to call from hot
|
|
31
|
+
# paths if needed for diagnostics.
|
|
32
|
+
def yjit_enabled?
|
|
33
|
+
defined?(::RubyVM::YJIT) && ::RubyVM::YJIT.enabled?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Whether the llhttp C extension loaded. False on JRuby/TruffleRuby and
|
|
37
|
+
# any environment where extconf.rb / make failed at install time. The
|
|
38
|
+
# pure-Ruby parser handles those cases correctly but is ~2× slower on
|
|
39
|
+
# parse-heavy workloads. Operators running production should confirm this
|
|
40
|
+
# returns true; CLI emits a startup banner if it doesn't.
|
|
41
|
+
def c_parser_available?
|
|
42
|
+
defined?(::Hyperion::CParser) && ::Hyperion::CParser.respond_to?(:build_response_head)
|
|
43
|
+
end
|
|
44
|
+
|
|
28
45
|
# Per-request access logging is ON by default — matches Puma/Rails operator
|
|
29
46
|
# expectations (Rails::Rack::Logger emits one line per request out of the
|
|
30
47
|
# box). Operators can disable it via `--no-log-requests`,
|
|
@@ -46,6 +63,44 @@ module Hyperion
|
|
|
46
63
|
else true # default ON
|
|
47
64
|
end
|
|
48
65
|
end
|
|
66
|
+
|
|
67
|
+
# Pre-fork warmup. Run by Master and CLI single-mode BEFORE children are
|
|
68
|
+
# forked (or before the lone worker starts accepting). Pre-allocates the
|
|
69
|
+
# Rack adapter's object pools and eager-touches lazily-resolved constants
|
|
70
|
+
# so each forked child inherits warm memory via copy-on-write — the first
|
|
71
|
+
# N requests on a fresh worker no longer pay the allocation / autoload
|
|
72
|
+
# tax that would otherwise serialize behind the GVL on cold start.
|
|
73
|
+
#
|
|
74
|
+
# Idempotent — second and later calls are no-ops. Failures are swallowed
|
|
75
|
+
# with a warn log: warmup is an optimization, not a correctness gate.
|
|
76
|
+
# If, for instance, OpenSSL can't be required in some odd environment,
|
|
77
|
+
# we'd rather start cold than refuse to boot.
|
|
78
|
+
def warmup!
|
|
79
|
+
return if @warmed
|
|
80
|
+
|
|
81
|
+
@warmed = true
|
|
82
|
+
|
|
83
|
+
if defined?(::Hyperion::Adapter::Rack) && ::Hyperion::Adapter::Rack.respond_to?(:warmup_pool)
|
|
84
|
+
::Hyperion::Adapter::Rack.warmup_pool(8)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Touch the C extension's response-head builder so its lazily-initialized
|
|
88
|
+
# internal state runs in the master, not in every child after fork.
|
|
89
|
+
::Hyperion::CParser.respond_to?(:build_response_head) if defined?(::Hyperion::CParser)
|
|
90
|
+
|
|
91
|
+
# Eager-load TLS / SSLSocket. The sendfile path's `is_a?` check would
|
|
92
|
+
# otherwise trigger autoload in the worker on the first TLS response.
|
|
93
|
+
require 'openssl'
|
|
94
|
+
defined?(::OpenSSL::SSL::SSLSocket) && ::OpenSSL::SSL::SSLSocket.name
|
|
95
|
+
|
|
96
|
+
# Force Ruby's tzinfo / strftime-cache load by emitting one httpdate.
|
|
97
|
+
# Subsequent calls hit the per-thread `cached_date` slot in response_writer.
|
|
98
|
+
Time.now.httpdate
|
|
99
|
+
nil
|
|
100
|
+
rescue StandardError => e
|
|
101
|
+
Hyperion.logger.warn { { message: 'warmup failed (non-fatal)', error: e.message } }
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
49
104
|
end
|
|
50
105
|
end
|
|
51
106
|
|
|
@@ -72,6 +127,8 @@ require_relative 'hyperion/request'
|
|
|
72
127
|
require_relative 'hyperion/parser'
|
|
73
128
|
require_relative 'hyperion/c_parser'
|
|
74
129
|
require_relative 'hyperion/adapter/rack'
|
|
130
|
+
require_relative 'hyperion/prometheus_exporter'
|
|
131
|
+
require_relative 'hyperion/admin_middleware'
|
|
75
132
|
require_relative 'hyperion/response_writer'
|
|
76
133
|
require_relative 'hyperion/thread_pool'
|
|
77
134
|
require_relative 'hyperion/connection'
|
|
@@ -79,4 +136,5 @@ require_relative 'hyperion/tls'
|
|
|
79
136
|
require_relative 'hyperion/http2_handler'
|
|
80
137
|
require_relative 'hyperion/server'
|
|
81
138
|
require_relative 'hyperion/worker'
|
|
139
|
+
require_relative 'hyperion/worker_health'
|
|
82
140
|
require_relative 'hyperion/master'
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: hyperion-rb
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.0
|
|
4
|
+
version: 1.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andrey Lobanov
|
|
@@ -148,6 +148,7 @@ files:
|
|
|
148
148
|
- lib/hyperion-rb.rb
|
|
149
149
|
- lib/hyperion.rb
|
|
150
150
|
- lib/hyperion/adapter/rack.rb
|
|
151
|
+
- lib/hyperion/admin_middleware.rb
|
|
151
152
|
- lib/hyperion/c_parser.rb
|
|
152
153
|
- lib/hyperion/cli.rb
|
|
153
154
|
- lib/hyperion/config.rb
|
|
@@ -159,6 +160,7 @@ files:
|
|
|
159
160
|
- lib/hyperion/metrics.rb
|
|
160
161
|
- lib/hyperion/parser.rb
|
|
161
162
|
- lib/hyperion/pool.rb
|
|
163
|
+
- lib/hyperion/prometheus_exporter.rb
|
|
162
164
|
- lib/hyperion/request.rb
|
|
163
165
|
- lib/hyperion/response_writer.rb
|
|
164
166
|
- lib/hyperion/server.rb
|
|
@@ -166,6 +168,7 @@ files:
|
|
|
166
168
|
- lib/hyperion/tls.rb
|
|
167
169
|
- lib/hyperion/version.rb
|
|
168
170
|
- lib/hyperion/worker.rb
|
|
171
|
+
- lib/hyperion/worker_health.rb
|
|
169
172
|
homepage: https://github.com/andrew-woblavobla/hyperion
|
|
170
173
|
licenses:
|
|
171
174
|
- MIT
|