hyperion-rb 1.6.2 → 2.11.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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +4768 -0
  3. data/README.md +222 -13
  4. data/ext/hyperion_h2_codec/Cargo.lock +7 -0
  5. data/ext/hyperion_h2_codec/Cargo.toml +33 -0
  6. data/ext/hyperion_h2_codec/extconf.rb +73 -0
  7. data/ext/hyperion_h2_codec/src/frames.rs +140 -0
  8. data/ext/hyperion_h2_codec/src/hpack/huffman.rs +161 -0
  9. data/ext/hyperion_h2_codec/src/hpack.rs +457 -0
  10. data/ext/hyperion_h2_codec/src/lib.rs +296 -0
  11. data/ext/hyperion_http/extconf.rb +28 -0
  12. data/ext/hyperion_http/h2_codec_glue.c +408 -0
  13. data/ext/hyperion_http/page_cache.c +1125 -0
  14. data/ext/hyperion_http/parser.c +473 -38
  15. data/ext/hyperion_http/sendfile.c +982 -0
  16. data/ext/hyperion_http/websocket.c +493 -0
  17. data/ext/hyperion_io_uring/Cargo.lock +33 -0
  18. data/ext/hyperion_io_uring/Cargo.toml +34 -0
  19. data/ext/hyperion_io_uring/extconf.rb +74 -0
  20. data/ext/hyperion_io_uring/src/lib.rs +316 -0
  21. data/lib/hyperion/adapter/rack.rb +370 -42
  22. data/lib/hyperion/admin_listener.rb +207 -0
  23. data/lib/hyperion/admin_middleware.rb +36 -7
  24. data/lib/hyperion/cli.rb +310 -11
  25. data/lib/hyperion/config.rb +440 -14
  26. data/lib/hyperion/connection.rb +679 -22
  27. data/lib/hyperion/deprecations.rb +81 -0
  28. data/lib/hyperion/dispatch_mode.rb +165 -0
  29. data/lib/hyperion/fiber_local.rb +75 -13
  30. data/lib/hyperion/h2_admission.rb +77 -0
  31. data/lib/hyperion/h2_codec.rb +499 -0
  32. data/lib/hyperion/http/page_cache.rb +122 -0
  33. data/lib/hyperion/http/sendfile.rb +696 -0
  34. data/lib/hyperion/http2/native_hpack_adapter.rb +70 -0
  35. data/lib/hyperion/http2_handler.rb +618 -19
  36. data/lib/hyperion/io_uring.rb +317 -0
  37. data/lib/hyperion/lint_wrapper_pool.rb +126 -0
  38. data/lib/hyperion/master.rb +96 -9
  39. data/lib/hyperion/metrics/path_templater.rb +68 -0
  40. data/lib/hyperion/metrics.rb +256 -0
  41. data/lib/hyperion/prometheus_exporter.rb +150 -0
  42. data/lib/hyperion/request.rb +13 -0
  43. data/lib/hyperion/response_writer.rb +477 -16
  44. data/lib/hyperion/runtime.rb +195 -0
  45. data/lib/hyperion/server/route_table.rb +179 -0
  46. data/lib/hyperion/server.rb +519 -55
  47. data/lib/hyperion/static_preload.rb +133 -0
  48. data/lib/hyperion/thread_pool.rb +61 -7
  49. data/lib/hyperion/tls.rb +343 -1
  50. data/lib/hyperion/version.rb +1 -1
  51. data/lib/hyperion/websocket/close_codes.rb +71 -0
  52. data/lib/hyperion/websocket/connection.rb +876 -0
  53. data/lib/hyperion/websocket/frame.rb +356 -0
  54. data/lib/hyperion/websocket/handshake.rb +525 -0
  55. data/lib/hyperion/worker.rb +111 -9
  56. data/lib/hyperion.rb +137 -3
  57. metadata +50 -1
@@ -29,6 +29,23 @@ module Hyperion
29
29
  # Public API:
30
30
  # Hyperion.stats -> Hash with all current values across all threads.
31
31
  class Metrics
32
+ class << self
33
+ # 2.4-C — process-wide default PathTemplater. Read by Connection at
34
+ # construction; written by the boot path (Worker / single-mode CLI)
35
+ # from `Hyperion::Config#metrics.path_templater`. Per-Connection
36
+ # override stays available via the `path_templater:` kwarg for
37
+ # specs and library users that build a Connection manually.
38
+ attr_writer :default_path_templater
39
+
40
+ def default_path_templater
41
+ @default_path_templater ||= PathTemplater.new
42
+ end
43
+
44
+ def reset_default_path_templater!
45
+ @default_path_templater = nil
46
+ end
47
+ end
48
+
32
49
  def initialize
33
50
  # Direct list of every per-thread counters Hash ever allocated through
34
51
  # this Metrics instance. We hold the Hash refs ourselves (instead of
@@ -41,6 +58,27 @@ module Hyperion
41
58
  # Per-instance thread-local key so spec runs that build fresh Metrics
42
59
  # objects don't share state across examples.
43
60
  @thread_key = :"__hyperion_metrics_#{object_id}__"
61
+
62
+ # 2.4-C — observability enrichment. Histograms and gauges live as
63
+ # separate keyed structures (vs counters) because the wire format
64
+ # is different (per-bucket cumulative counts + sum/count for
65
+ # histograms; a single instantaneous reading for gauges). Both are
66
+ # mutex-guarded — these are scrape-rate operations (one observe per
67
+ # request, one set per worker boot/shutdown), not per-syscall.
68
+ #
69
+ # Histograms: `{ name => { labels_tuple_array => HistogramAccumulator } }`.
70
+ # Gauges: `{ name => { labels_tuple_array => Float } }`.
71
+ # `labels_tuple_array` is a frozen Array<String> of label values
72
+ # (stable order, supplied by the observer); it doubles as the Hash
73
+ # key for cheap O(1) lookup.
74
+ @histograms = {}
75
+ @histograms_meta = {} # name => { buckets:, label_keys: }
76
+ @gauges = {}
77
+ @gauges_meta = {} # name => { label_keys: }
78
+ @hg_mutex = Mutex.new
79
+ # Snapshot block hooks for gauges whose value is read on demand
80
+ # (ThreadPool queue depth, etc.). `{ name => { labels_tuple => Proc } }`.
81
+ @gauge_blocks = {}
44
82
  end
45
83
 
46
84
  # Hot path: one thread-variable lookup + one hash op. No mutex on the
@@ -90,6 +128,222 @@ module Hyperion
90
128
  @counters_mutex.synchronize do
91
129
  @thread_counters.each(&:clear)
92
130
  end
131
+ @hg_mutex.synchronize do
132
+ @histograms.each_value(&:clear)
133
+ @gauges.each_value(&:clear)
134
+ @gauge_blocks.each_value(&:clear)
135
+ end
136
+ end
137
+
138
+ # ---- 2.4-C histogram + gauge API ---------------------------------
139
+
140
+ # Register a histogram family. Idempotent — re-registering with the
141
+ # same buckets/label_keys is a no-op; mismatched re-register raises
142
+ # so a typo surfaces at boot rather than corrupting the scrape output.
143
+ def register_histogram(name, buckets:, label_keys: [])
144
+ @hg_mutex.synchronize do
145
+ if (existing = @histograms_meta[name])
146
+ unless existing[:buckets] == buckets && existing[:label_keys] == label_keys
147
+ raise ArgumentError,
148
+ "histogram #{name.inspect} re-registered with different shape " \
149
+ "(was buckets=#{existing[:buckets]} labels=#{existing[:label_keys]}; " \
150
+ "now buckets=#{buckets} labels=#{label_keys})"
151
+ end
152
+
153
+ return
154
+ end
155
+ @histograms_meta[name] = { buckets: buckets.dup.freeze, label_keys: label_keys.dup.freeze }
156
+ @histograms[name] = {}
157
+ end
158
+ end
159
+
160
+ # Observe `value` on a previously-registered histogram. `label_values`
161
+ # MUST be supplied in the same order as `label_keys` at registration.
162
+ # The hot path: one Hash lookup, one accumulator update under a mutex.
163
+ # Allocation footprint per observe: zero on the cached-key path
164
+ # (same labels seen before); one frozen Array on first observation
165
+ # for a given label-set.
166
+ def observe_histogram(name, value, label_values = EMPTY_LABELS)
167
+ @hg_mutex.synchronize do
168
+ meta = @histograms_meta[name]
169
+ return unless meta # silently skip unregistered observations
170
+
171
+ family = @histograms[name]
172
+ accum = family[label_values]
173
+ unless accum
174
+ accum = HistogramAccumulator.new(meta[:buckets])
175
+ # Freeze the label tuple so future identical-content tuples
176
+ # hash to the same bucket — but we keep the original ref
177
+ # provided by the caller as the canonical key so subsequent
178
+ # observes with the same Array bypass the freeze step.
179
+ family[label_values.frozen? ? label_values : label_values.dup.freeze] = accum
180
+ end
181
+ accum.observe(value)
182
+ end
183
+ end
184
+
185
+ # Set a gauge value. `label_values` follows the same convention as
186
+ # `observe_histogram`. Pass a block to register a callback that's
187
+ # evaluated lazily at snapshot time (ThreadPool queue depth, etc.) —
188
+ # the callback's return value is the gauge's current reading.
189
+ def set_gauge(name, value = nil, label_values = EMPTY_LABELS, &block)
190
+ @hg_mutex.synchronize do
191
+ @gauges_meta[name] ||= { label_keys: [].freeze }
192
+ if block
193
+ (@gauge_blocks[name] ||= {})[label_values.frozen? ? label_values : label_values.dup.freeze] = block
194
+ else
195
+ (@gauges[name] ||= {})[label_values.frozen? ? label_values : label_values.dup.freeze] = value.to_f
196
+ end
197
+ end
198
+ end
199
+
200
+ # Increment a gauge by `delta` (default 1). Used for kTLS active
201
+ # connections, etc. — paired with `decrement_gauge` on close.
202
+ def increment_gauge(name, label_values = EMPTY_LABELS, delta = 1)
203
+ @hg_mutex.synchronize do
204
+ @gauges_meta[name] ||= { label_keys: [].freeze }
205
+ family = (@gauges[name] ||= {})
206
+ key = label_values.frozen? ? label_values : label_values.dup.freeze
207
+ family[key] = (family[key] || 0.0) + delta.to_f
208
+ end
209
+ end
210
+
211
+ def decrement_gauge(name, label_values = EMPTY_LABELS, delta = 1)
212
+ increment_gauge(name, label_values, -delta)
213
+ end
214
+
215
+ # Register that a histogram/gauge family exists with this label
216
+ # ordering. The PrometheusExporter calls `histogram_meta` /
217
+ # `gauge_meta` at scrape time to build the HELP/TYPE preamble.
218
+ def histogram_meta(name)
219
+ @hg_mutex.synchronize { @histograms_meta[name]&.dup }
220
+ end
221
+
222
+ def gauge_meta(name)
223
+ @hg_mutex.synchronize { @gauges_meta[name]&.dup }
224
+ end
225
+
226
+ # Snapshot helpers — read-only views of the current histogram /
227
+ # gauge state. The exporter uses these to render the scrape body.
228
+ def histogram_snapshot
229
+ out = {}
230
+ @hg_mutex.synchronize do
231
+ @histograms.each do |name, family|
232
+ per_labels = {}
233
+ family.each { |labels, accum| per_labels[labels] = accum.snapshot }
234
+ out[name] = { meta: @histograms_meta[name], series: per_labels }
235
+ end
236
+ end
237
+ out
238
+ end
239
+
240
+ def gauge_snapshot
241
+ out = {}
242
+ @hg_mutex.synchronize do
243
+ names = (@gauges.keys + @gauge_blocks.keys).uniq
244
+ names.each do |name|
245
+ per_labels = {}
246
+ @gauges[name]&.each { |labels, value| per_labels[labels] = value.to_f }
247
+ @gauge_blocks[name]&.each do |labels, block|
248
+ # Block-evaluated gauges read live state at scrape time. We
249
+ # release the mutex around the block call to avoid holding
250
+ # while user code runs, BUT we currently hold @hg_mutex —
251
+ # the contract is that the block is short and side-effect-
252
+ # free (e.g., reads ThreadPool#queue_size). That's the only
253
+ # use case we wire today; document if extended.
254
+ per_labels[labels] = block.call.to_f
255
+ rescue StandardError
256
+ # Snapshot must never raise — a misbehaving block degrades
257
+ # to "no reading" rather than a 500 on /-/metrics.
258
+ next
259
+ end
260
+ out[name] = { meta: @gauges_meta[name] || { label_keys: [].freeze }, series: per_labels }
261
+ end
262
+ end
263
+ out
264
+ end
265
+
266
+ # Frozen empty Array used as the default label tuple. Reused across
267
+ # all label-less observations so we don't allocate a fresh `[]` per
268
+ # scrape — keeps hot-path work allocation-free for the un-labeled
269
+ # gauge/histogram families.
270
+ EMPTY_LABELS = [].freeze
271
+
272
+ # Labeled counter — separate from the legacy thread-local counter
273
+ # surface (which is unlabeled and per-thread for hot-path
274
+ # contention-free increments). Labeled counters take a mutex per
275
+ # increment, but they're called from low-rate paths (per-conn
276
+ # rejection ~ kHz worst case, vs M+req/s on the unlabeled side)
277
+ # so the contention cost is invisible.
278
+ def increment_labeled_counter(name, label_values = EMPTY_LABELS, by = 1)
279
+ @hg_mutex.synchronize do
280
+ @labeled_counters_meta ||= {}
281
+ @labeled_counters_meta[name] ||= { label_keys: [].freeze }
282
+ @labeled_counters ||= {}
283
+ family = (@labeled_counters[name] ||= {})
284
+ key = label_values.frozen? ? label_values : label_values.dup.freeze
285
+ family[key] = (family[key] || 0) + by
286
+ end
287
+ end
288
+
289
+ def register_labeled_counter(name, label_keys: [])
290
+ @hg_mutex.synchronize do
291
+ @labeled_counters_meta ||= {}
292
+ @labeled_counters_meta[name] = { label_keys: label_keys.dup.freeze }
293
+ @labeled_counters ||= {}
294
+ @labeled_counters[name] ||= {}
295
+ end
296
+ end
297
+
298
+ def labeled_counter_snapshot
299
+ out = {}
300
+ @hg_mutex.synchronize do
301
+ (@labeled_counters || {}).each do |name, family|
302
+ per_labels = {}
303
+ family.each { |labels, count| per_labels[labels] = count }
304
+ meta = (@labeled_counters_meta || {})[name] || { label_keys: [].freeze }
305
+ out[name] = { meta: meta, series: per_labels }
306
+ end
307
+ end
308
+ out
309
+ end
310
+
311
+ # Per-(name, labels) histogram accumulator. Fixed-size Integer Array
312
+ # of bucket counters + scalar sum/count. Cumulative bucket semantics
313
+ # match Prometheus client convention: bucket[i] counts observations
314
+ # whose value <= bucket_edges[i], and the implicit `+Inf` bucket is
315
+ # `count` itself. The exporter writes the +Inf bucket as the total
316
+ # count plus a `le="+Inf"` line per Prometheus text format.
317
+ class HistogramAccumulator
318
+ attr_reader :buckets, :counts, :sum, :count
319
+
320
+ def initialize(buckets)
321
+ @buckets = buckets.freeze
322
+ @counts = Array.new(buckets.size, 0)
323
+ @sum = 0.0
324
+ @count = 0
325
+ end
326
+
327
+ # Walk the buckets linearly. For 7 buckets (the default request-
328
+ # duration set) this is faster than binary search; for any
329
+ # reasonable bucket count (< 30) the constant factor wins. Mutex-
330
+ # guarded by the caller (Metrics#observe_histogram).
331
+ def observe(value)
332
+ v = value.to_f
333
+ @sum += v
334
+ @count += 1
335
+ i = 0
336
+ len = @buckets.length
337
+ while i < len
338
+ @counts[i] += 1 if v <= @buckets[i]
339
+ i += 1
340
+ end
341
+ end
342
+
343
+ def snapshot
344
+ # Return a new struct so callers don't see a live, mutating ref.
345
+ { buckets: @buckets, counts: @counts.dup, sum: @sum, count: @count }
346
+ end
93
347
  end
94
348
 
95
349
  private
@@ -102,3 +356,5 @@ module Hyperion
102
356
  end
103
357
  end
104
358
  end
359
+
360
+ require_relative 'metrics/path_templater'
@@ -46,6 +46,37 @@ module Hyperion
46
46
  STATUS_FAMILY_NAME = 'hyperion_responses_status_total'
47
47
  STATUS_FAMILY_HELP = 'Responses by HTTP status code'
48
48
 
49
+ # 2.4-C: curated HELP/TYPE preamble for the new histogram + gauge +
50
+ # labeled-counter families. Looking up by name keeps the rendered
51
+ # scrape body human-friendly even when the caller registered the
52
+ # family from a deep code path with no docstring.
53
+ METRIC_DOCS = {
54
+ hyperion_request_duration_seconds: {
55
+ help: 'HTTP request duration in seconds, by route template + method + status class',
56
+ type: 'histogram'
57
+ },
58
+ hyperion_websocket_deflate_ratio: {
59
+ help: 'WebSocket permessage-deflate compression ratio (original_bytes / compressed_bytes)',
60
+ type: 'histogram'
61
+ },
62
+ hyperion_per_conn_rejections_total: {
63
+ help: 'Per-connection in-flight cap rejections (503 + Retry-After), by worker',
64
+ type: 'counter'
65
+ },
66
+ hyperion_tls_ktls_active_connections: {
67
+ help: 'Active TLS connections currently driven by kernel TLS_TX, by worker',
68
+ type: 'gauge'
69
+ },
70
+ hyperion_io_uring_workers_active: {
71
+ help: 'Whether io_uring accept policy is active for this worker (1 = active, 0 = epoll)',
72
+ type: 'gauge'
73
+ },
74
+ hyperion_threadpool_queue_depth: {
75
+ help: 'In-flight count in the worker ThreadPool inbox (snapshot at scrape time)',
76
+ type: 'gauge'
77
+ }
78
+ }.freeze
79
+
49
80
  def render(stats)
50
81
  buf = +''
51
82
  grouped_status = {}
@@ -86,6 +117,125 @@ module Hyperion
86
117
  buf
87
118
  end
88
119
 
120
+ # 2.4-C — render histograms, gauges, and labeled counters from a live
121
+ # Metrics instance. Called by AdminMiddleware in addition to `render`
122
+ # so /-/metrics surfaces the full 2.4-C observability surface in one
123
+ # scrape body. Order: legacy counters first (existing render), then
124
+ # histograms, gauges, labeled counters — each curated families first,
125
+ # auto-exported last, alphabetical within each section so scrape
126
+ # diffs stay stable.
127
+ def render_full(metrics_sink)
128
+ buf = +''
129
+ buf << render(metrics_sink.snapshot)
130
+ buf << render_histograms(metrics_sink.histogram_snapshot)
131
+ buf << render_gauges(metrics_sink.gauge_snapshot)
132
+ buf << render_labeled_counters(metrics_sink.labeled_counter_snapshot)
133
+ buf
134
+ end
135
+
136
+ def render_histograms(snap)
137
+ buf = +''
138
+ snap.each do |name, payload|
139
+ meta = payload[:meta]
140
+ series = payload[:series]
141
+ next if meta.nil?
142
+
143
+ doc = METRIC_DOCS[name] || { help: 'Hyperion histogram', type: 'histogram' }
144
+ buf << "# HELP #{name} #{doc[:help]}\n"
145
+ buf << "# TYPE #{name} histogram\n"
146
+ series.each do |label_values, snap_data|
147
+ render_histogram_series(buf, name, meta[:label_keys], label_values, snap_data)
148
+ end
149
+ end
150
+ buf
151
+ end
152
+
153
+ def render_gauges(snap)
154
+ buf = +''
155
+ snap.each do |name, payload|
156
+ series = payload[:series]
157
+ meta = payload[:meta] || { label_keys: [].freeze }
158
+ doc = METRIC_DOCS[name] || { help: 'Hyperion gauge', type: 'gauge' }
159
+ buf << "# HELP #{name} #{doc[:help]}\n"
160
+ buf << "# TYPE #{name} gauge\n"
161
+ series.each do |label_values, value|
162
+ buf << render_labeled_value(name, meta[:label_keys], label_values, value)
163
+ end
164
+ end
165
+ buf
166
+ end
167
+
168
+ def render_labeled_counters(snap)
169
+ buf = +''
170
+ snap.each do |name, payload|
171
+ series = payload[:series]
172
+ meta = payload[:meta] || { label_keys: [].freeze }
173
+ doc = METRIC_DOCS[name] || { help: 'Hyperion labeled counter', type: 'counter' }
174
+ buf << "# HELP #{name} #{doc[:help]}\n"
175
+ buf << "# TYPE #{name} counter\n"
176
+ series.each do |label_values, value|
177
+ buf << render_labeled_value(name, meta[:label_keys], label_values, value)
178
+ end
179
+ end
180
+ buf
181
+ end
182
+
183
+ def render_histogram_series(buf, name, label_keys, label_values, snap_data)
184
+ buckets = snap_data[:buckets]
185
+ counts = snap_data[:counts]
186
+ label_keys ||= []
187
+ label_keys = label_keys.size != label_values.size ? %w[method path status][0, label_values.size] : label_keys
188
+ base_pairs = label_keys.zip(label_values)
189
+ buckets.each_with_index do |edge, idx|
190
+ pairs = base_pairs + [['le', format_float(edge)]]
191
+ buf << "#{name}_bucket#{labels_block(pairs)} #{counts[idx]}\n"
192
+ end
193
+ pairs_inf = base_pairs + [['le', '+Inf']]
194
+ buf << "#{name}_bucket#{labels_block(pairs_inf)} #{snap_data[:count]}\n"
195
+ buf << "#{name}_sum#{labels_block(base_pairs)} #{snap_data[:sum]}\n"
196
+ buf << "#{name}_count#{labels_block(base_pairs)} #{snap_data[:count]}\n"
197
+ end
198
+
199
+ def render_labeled_value(name, label_keys, label_values, value)
200
+ label_keys ||= []
201
+ label_keys = label_keys.size == label_values.size ? label_keys : default_label_keys(label_values.size)
202
+ pairs = label_keys.zip(label_values)
203
+ "#{name}#{labels_block(pairs)} #{value}\n"
204
+ end
205
+
206
+ def default_label_keys(n)
207
+ Array.new(n) { |i| "label_#{i}" }
208
+ end
209
+
210
+ def labels_block(pairs)
211
+ return '' if pairs.empty?
212
+
213
+ inside = pairs.map { |k, v| %(#{k}="#{escape_label(v.to_s)}") }.join(',')
214
+ "{#{inside}}"
215
+ end
216
+
217
+ def escape_label(value)
218
+ out = +''
219
+ value.each_char do |c|
220
+ out << case c
221
+ when '\\' then '\\\\'
222
+ when '"' then '\\"'
223
+ when "\n" then '\\n'
224
+ else
225
+ c
226
+ end
227
+ end
228
+ out
229
+ end
230
+
231
+ # Render histogram bucket edges with a stable representation. Integer-
232
+ # valued floats stay as `2.5` (not `2.5000000000000004`); fractional
233
+ # ones round to 6 places, plenty for scrape stability.
234
+ def format_float(v)
235
+ f = v.to_f
236
+ f == f.to_i ? f.to_i.to_s : format('%.6g', f)
237
+ end
238
+
89
239
  def append_metric(buf, name, help, type, value)
90
240
  buf << "# HELP #{name} #{help}\n"
91
241
  buf << "# TYPE #{name} #{type}\n"
@@ -18,7 +18,20 @@ module Hyperion
18
18
  freeze
19
19
  end
20
20
 
21
+ # Case-insensitive header lookup. Phase 11 — Hyperion's parser stores
22
+ # header names lowercased (the parser's normalisation contract), and
23
+ # the in-tree hot-path callers (Adapter::Rack#build_env,
24
+ # Connection#should_keep_alive?, Handshake#validate) all pass frozen
25
+ # lowercase literals. Pre-Phase-11 the unconditional `name.downcase`
26
+ # allocated a redundant copy per call. Fast-path direct hash lookup;
27
+ # only fall through to `downcase` when the literal lookup misses,
28
+ # which preserves the case-insensitive contract for mixed-case callers
29
+ # (specs, third-party middleware) without paying the allocation on
30
+ # every request.
21
31
  def header(name)
32
+ v = @headers[name]
33
+ return v unless v.nil?
34
+
22
35
  @headers[name.downcase]
23
36
  end
24
37
  end