hyperion-rb 2.11.0 → 2.13.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 +1079 -0
- data/README.md +220 -5
- data/ext/hyperion_http/extconf.rb +41 -0
- data/ext/hyperion_http/io_uring_loop.c +710 -0
- data/ext/hyperion_http/page_cache.c +1032 -0
- data/ext/hyperion_http/page_cache_internal.h +132 -0
- data/ext/hyperion_http/parser.c +382 -51
- data/lib/hyperion/adapter/rack.rb +18 -4
- data/lib/hyperion/connection.rb +78 -3
- data/lib/hyperion/dispatch_mode.rb +19 -1
- data/lib/hyperion/http2_handler.rb +458 -13
- data/lib/hyperion/metrics.rb +212 -38
- data/lib/hyperion/prometheus_exporter.rb +76 -1
- data/lib/hyperion/server/connection_loop.rb +159 -0
- data/lib/hyperion/server.rb +183 -0
- data/lib/hyperion/thread_pool.rb +23 -7
- data/lib/hyperion/version.rb +1 -1
- metadata +4 -1
data/lib/hyperion/metrics.rb
CHANGED
|
@@ -79,6 +79,30 @@ module Hyperion
|
|
|
79
79
|
# Snapshot block hooks for gauges whose value is read on demand
|
|
80
80
|
# (ThreadPool queue depth, etc.). `{ name => { labels_tuple => Proc } }`.
|
|
81
81
|
@gauge_blocks = {}
|
|
82
|
+
|
|
83
|
+
# 2.13-A — per-thread shards for the hot-path metrics that USED to
|
|
84
|
+
# take @hg_mutex on every observe / increment. The pre-2.13-A
|
|
85
|
+
# comment in `increment_labeled_counter` claimed those paths were
|
|
86
|
+
# "low-rate" — that turned out to be wrong: `tick_worker_request`
|
|
87
|
+
# fires once per Rack request, and `observe_histogram` fires once
|
|
88
|
+
# per request via the per-route duration histogram. Under -t 32
|
|
89
|
+
# the single mutex serialised every worker thread on the
|
|
90
|
+
# request-completion tail. Per-thread shards remove the
|
|
91
|
+
# contention; snapshots merge across threads under the mutex
|
|
92
|
+
# (snapshot is a low-rate operation — once per /-/metrics scrape).
|
|
93
|
+
#
|
|
94
|
+
# Thread-variable storage (NOT Thread.current[]) for the same
|
|
95
|
+
# reason as the unlabeled counter path: under an Async scheduler
|
|
96
|
+
# `Thread.current[:k]` is fiber-local, which would let snapshots
|
|
97
|
+
# miss observations made on a fiber that already exited.
|
|
98
|
+
@hg_thread_key = :"__hyperion_metrics_hg_#{object_id}__"
|
|
99
|
+
@lc_thread_key = :"__hyperion_metrics_lc_#{object_id}__"
|
|
100
|
+
# Holds direct references to every per-thread shard ever
|
|
101
|
+
# allocated through this Metrics instance (mirrors @thread_counters)
|
|
102
|
+
# so snapshots survive thread death.
|
|
103
|
+
@thread_histograms = []
|
|
104
|
+
@thread_labeled_counters = []
|
|
105
|
+
@hg_thread_mutex = Mutex.new
|
|
82
106
|
end
|
|
83
107
|
|
|
84
108
|
# Hot path: one thread-variable lookup + one hash op. No mutex on the
|
|
@@ -113,6 +137,44 @@ module Hyperion
|
|
|
113
137
|
increment(:"responses_#{code}")
|
|
114
138
|
end
|
|
115
139
|
|
|
140
|
+
# 2.12-E — labeled counter family that observes which worker
|
|
141
|
+
# process a given request landed on. Ticks once per dispatched
|
|
142
|
+
# request from every dispatch shape (Connection#serve, h2 streams,
|
|
143
|
+
# the C accept4 + io_uring loops; see PrometheusExporter for the
|
|
144
|
+
# C-loop fold-in at scrape time).
|
|
145
|
+
#
|
|
146
|
+
# `worker_id` is conventionally `Process.pid.to_s` — matches the
|
|
147
|
+
# 2.4-C `hyperion_io_uring_workers_active` and
|
|
148
|
+
# `hyperion_per_conn_rejections_total` labeling convention; lets
|
|
149
|
+
# operators correlate distribution rows with `ps`/`/proc` data
|
|
150
|
+
# without a separate worker_id <-> pid mapping table.
|
|
151
|
+
#
|
|
152
|
+
# Hot-path cost: one `@hg_mutex` acquisition per tick. That's
|
|
153
|
+
# acceptable for the audit metric: contention shows up only on
|
|
154
|
+
# the `tick + render` overlap, never inside the C accept loop
|
|
155
|
+
# (which uses its own atomic counter folded in at scrape time).
|
|
156
|
+
# Worth the simplicity over an extra lock-free per-thread cache.
|
|
157
|
+
REQUESTS_DISPATCH_TOTAL = :hyperion_requests_dispatch_total
|
|
158
|
+
WORKER_ID_LABEL_KEYS = %w[worker_id].freeze
|
|
159
|
+
|
|
160
|
+
def tick_worker_request(worker_id)
|
|
161
|
+
label = worker_id.nil? || worker_id.to_s.empty? ? '0' : worker_id.to_s
|
|
162
|
+
ensure_worker_request_family_registered!
|
|
163
|
+
increment_labeled_counter(REQUESTS_DISPATCH_TOTAL, [label])
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# 2.12-E — Idempotently register the labeled-counter family. Public
|
|
167
|
+
# so `Server#run_c_accept_loop` can register at boot — the
|
|
168
|
+
# PrometheusExporter's C-loop fold-in is gated on the family being
|
|
169
|
+
# in the snapshot, and a 100% C-loop worker never goes through
|
|
170
|
+
# `tick_worker_request` to register lazily.
|
|
171
|
+
def ensure_worker_request_family_registered!
|
|
172
|
+
return if @worker_request_family_registered
|
|
173
|
+
|
|
174
|
+
register_labeled_counter(REQUESTS_DISPATCH_TOTAL, label_keys: WORKER_ID_LABEL_KEYS)
|
|
175
|
+
@worker_request_family_registered = true
|
|
176
|
+
end
|
|
177
|
+
|
|
116
178
|
def snapshot
|
|
117
179
|
result = Hash.new(0)
|
|
118
180
|
counters_snapshot = @counters_mutex.synchronize { @thread_counters.dup }
|
|
@@ -124,6 +186,12 @@ module Hyperion
|
|
|
124
186
|
end
|
|
125
187
|
|
|
126
188
|
# Tests can call .reset! between examples to avoid cross-spec leakage.
|
|
189
|
+
#
|
|
190
|
+
# 2.13-A — also clear per-thread histogram and labeled-counter
|
|
191
|
+
# shards. Without this, an observation made on thread A in spec X
|
|
192
|
+
# would leak into spec Y's snapshot because the shard hashes are
|
|
193
|
+
# held alive by `@thread_histograms` / `@thread_labeled_counters`
|
|
194
|
+
# for the lifetime of the Metrics instance.
|
|
127
195
|
def reset!
|
|
128
196
|
@counters_mutex.synchronize do
|
|
129
197
|
@thread_counters.each(&:clear)
|
|
@@ -133,6 +201,10 @@ module Hyperion
|
|
|
133
201
|
@gauges.each_value(&:clear)
|
|
134
202
|
@gauge_blocks.each_value(&:clear)
|
|
135
203
|
end
|
|
204
|
+
@hg_thread_mutex.synchronize do
|
|
205
|
+
@thread_histograms.each(&:clear)
|
|
206
|
+
@thread_labeled_counters.each(&:clear)
|
|
207
|
+
end
|
|
136
208
|
end
|
|
137
209
|
|
|
138
210
|
# ---- 2.4-C histogram + gauge API ---------------------------------
|
|
@@ -159,27 +231,37 @@ module Hyperion
|
|
|
159
231
|
|
|
160
232
|
# Observe `value` on a previously-registered histogram. `label_values`
|
|
161
233
|
# MUST be supplied in the same order as `label_keys` at registration.
|
|
162
|
-
#
|
|
163
|
-
#
|
|
164
|
-
#
|
|
165
|
-
#
|
|
234
|
+
#
|
|
235
|
+
# 2.13-A — Hot-path lock-free shard. Each thread keeps its own
|
|
236
|
+
# `{ name => { labels => HistogramAccumulator } }` map; observations
|
|
237
|
+
# never block on `@hg_mutex`. Snapshots merge across threads under
|
|
238
|
+
# the mutex (low-rate). Allocation footprint per observe: zero on
|
|
239
|
+
# the cached-key path; one frozen Array + one HistogramAccumulator
|
|
240
|
+
# on first observation for a given (name, label-set, thread).
|
|
241
|
+
#
|
|
242
|
+
# Bench impact (generic Rack hello, -t 32 -c 100):
|
|
243
|
+
# contention on `@hg_mutex` was the dominant tail latency
|
|
244
|
+
# contributor — this fires once per request via the per-route
|
|
245
|
+
# request-duration histogram, multiplied by N worker threads.
|
|
166
246
|
def observe_histogram(name, value, label_values = EMPTY_LABELS)
|
|
167
|
-
@
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
247
|
+
meta = @histograms_meta[name]
|
|
248
|
+
return unless meta # silently skip unregistered observations
|
|
249
|
+
|
|
250
|
+
thread = Thread.current
|
|
251
|
+
shard = thread.thread_variable_get(@hg_thread_key)
|
|
252
|
+
shard = register_thread_histograms(thread) if shard.nil?
|
|
253
|
+
|
|
254
|
+
family = (shard[name] ||= {})
|
|
255
|
+
accum = family[label_values]
|
|
256
|
+
unless accum
|
|
257
|
+
accum = HistogramAccumulator.new(meta[:buckets])
|
|
258
|
+
# Freeze the label tuple so future identical-content tuples
|
|
259
|
+
# hash to the same bucket — but we keep the original ref
|
|
260
|
+
# provided by the caller as the canonical key so subsequent
|
|
261
|
+
# observes with the same Array bypass the freeze step.
|
|
262
|
+
family[label_values.frozen? ? label_values : label_values.dup.freeze] = accum
|
|
182
263
|
end
|
|
264
|
+
accum.observe(value)
|
|
183
265
|
end
|
|
184
266
|
|
|
185
267
|
# Set a gauge value. `label_values` follows the same convention as
|
|
@@ -225,15 +307,40 @@ module Hyperion
|
|
|
225
307
|
|
|
226
308
|
# Snapshot helpers — read-only views of the current histogram /
|
|
227
309
|
# gauge state. The exporter uses these to render the scrape body.
|
|
310
|
+
#
|
|
311
|
+
# 2.13-A — Histograms merge across the per-thread shards on the
|
|
312
|
+
# snapshot path. The mutex is held only long enough to copy the
|
|
313
|
+
# shard list (every shard Hash is owned by one thread, so we can
|
|
314
|
+
# iterate its current contents safely while merging — torn reads
|
|
315
|
+
# of in-progress observations show as a slightly stale snapshot,
|
|
316
|
+
# never as a corrupted Accumulator).
|
|
228
317
|
def histogram_snapshot
|
|
229
318
|
out = {}
|
|
319
|
+
|
|
320
|
+
# Pre-seed names from registered families so a histogram with
|
|
321
|
+
# zero observations still appears in the scrape (matches the
|
|
322
|
+
# pre-2.13-A behaviour where `register_histogram` populated the
|
|
323
|
+
# `@histograms[name] = {}` slot eagerly).
|
|
230
324
|
@hg_mutex.synchronize do
|
|
231
|
-
@
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
325
|
+
@histograms_meta.each_key { |name| out[name] = { meta: @histograms_meta[name], series: {} } }
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
shards = @hg_thread_mutex.synchronize { @thread_histograms.dup }
|
|
329
|
+
shards.each do |shard|
|
|
330
|
+
shard.each do |name, family|
|
|
331
|
+
slot = (out[name] ||= { meta: @histograms_meta[name], series: {} })
|
|
332
|
+
series = slot[:series]
|
|
333
|
+
family.each do |labels, accum|
|
|
334
|
+
existing = series[labels]
|
|
335
|
+
if existing.nil?
|
|
336
|
+
series[labels] = accum.snapshot
|
|
337
|
+
else
|
|
338
|
+
merge_histogram_snapshot!(existing, accum)
|
|
339
|
+
end
|
|
340
|
+
end
|
|
235
341
|
end
|
|
236
342
|
end
|
|
343
|
+
|
|
237
344
|
out
|
|
238
345
|
end
|
|
239
346
|
|
|
@@ -271,19 +378,33 @@ module Hyperion
|
|
|
271
378
|
|
|
272
379
|
# Labeled counter — separate from the legacy thread-local counter
|
|
273
380
|
# surface (which is unlabeled and per-thread for hot-path
|
|
274
|
-
# contention-free increments).
|
|
275
|
-
#
|
|
276
|
-
#
|
|
277
|
-
#
|
|
381
|
+
# contention-free increments).
|
|
382
|
+
#
|
|
383
|
+
# 2.13-A — moved to a per-thread shard for the same reason as
|
|
384
|
+
# `observe_histogram`: the previous "low-rate paths" claim was wrong
|
|
385
|
+
# (`tick_worker_request` is per-Rack-request), and at -t 32 the
|
|
386
|
+
# single mutex serialised every worker thread on the request-
|
|
387
|
+
# completion tail. Per-thread shards remove the contention;
|
|
388
|
+
# `labeled_counter_snapshot` merges shards under the mutex.
|
|
278
389
|
def increment_labeled_counter(name, label_values = EMPTY_LABELS, by = 1)
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
390
|
+
thread = Thread.current
|
|
391
|
+
shard = thread.thread_variable_get(@lc_thread_key)
|
|
392
|
+
shard = register_thread_labeled_counters(thread) if shard.nil?
|
|
393
|
+
|
|
394
|
+
# Defensive: ensure the family meta exists so `register_labeled_counter`
|
|
395
|
+
# is not strictly required for hot-path increments. Pre-2.13-A the
|
|
396
|
+
# mutex'd path lazily registered an unlabeled meta; we mirror that
|
|
397
|
+
# under @hg_mutex so the shape stays consistent across threads.
|
|
398
|
+
unless @labeled_counters_meta && @labeled_counters_meta[name]
|
|
399
|
+
@hg_mutex.synchronize do
|
|
400
|
+
@labeled_counters_meta ||= {}
|
|
401
|
+
@labeled_counters_meta[name] ||= { label_keys: [].freeze }
|
|
402
|
+
end
|
|
286
403
|
end
|
|
404
|
+
|
|
405
|
+
family = (shard[name] ||= {})
|
|
406
|
+
key = label_values.frozen? ? label_values : label_values.dup.freeze
|
|
407
|
+
family[key] = (family[key] || 0) + by
|
|
287
408
|
end
|
|
288
409
|
|
|
289
410
|
def register_labeled_counter(name, label_keys: [])
|
|
@@ -295,14 +416,27 @@ module Hyperion
|
|
|
295
416
|
end
|
|
296
417
|
end
|
|
297
418
|
|
|
419
|
+
# 2.13-A — Snapshot merges per-thread shards. Pre-seeded with
|
|
420
|
+
# `@labeled_counters_meta` so registered-but-unticked families still
|
|
421
|
+
# show up in the scrape (matches pre-2.13-A behaviour where
|
|
422
|
+
# `register_labeled_counter` eagerly created the `[name] = {}` slot).
|
|
298
423
|
def labeled_counter_snapshot
|
|
299
424
|
out = {}
|
|
300
425
|
@hg_mutex.synchronize do
|
|
301
|
-
(@
|
|
302
|
-
|
|
303
|
-
|
|
426
|
+
(@labeled_counters_meta || {}).each do |name, meta|
|
|
427
|
+
out[name] = { meta: meta, series: {} }
|
|
428
|
+
end
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
shards = @hg_thread_mutex.synchronize { @thread_labeled_counters.dup }
|
|
432
|
+
shards.each do |shard|
|
|
433
|
+
shard.each do |name, family|
|
|
304
434
|
meta = (@labeled_counters_meta || {})[name] || { label_keys: [].freeze }
|
|
305
|
-
out[name]
|
|
435
|
+
slot = (out[name] ||= { meta: meta, series: {} })
|
|
436
|
+
series = slot[:series]
|
|
437
|
+
family.each do |labels, count|
|
|
438
|
+
series[labels] = (series[labels] || 0) + count
|
|
439
|
+
end
|
|
306
440
|
end
|
|
307
441
|
end
|
|
308
442
|
out
|
|
@@ -354,6 +488,46 @@ module Hyperion
|
|
|
354
488
|
@counters_mutex.synchronize { @thread_counters << counters }
|
|
355
489
|
counters
|
|
356
490
|
end
|
|
491
|
+
|
|
492
|
+
# 2.13-A — allocate this thread's histogram shard and register it
|
|
493
|
+
# in `@thread_histograms` so snapshots find it. Idempotent per
|
|
494
|
+
# thread: callers always check `thread_variable_get` first.
|
|
495
|
+
def register_thread_histograms(thread)
|
|
496
|
+
shard = {}
|
|
497
|
+
thread.thread_variable_set(@hg_thread_key, shard)
|
|
498
|
+
@hg_thread_mutex.synchronize { @thread_histograms << shard }
|
|
499
|
+
shard
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
# 2.13-A — same shape as register_thread_histograms but for labeled
|
|
503
|
+
# counters. Each thread gets its own `{ name => { labels => Integer } }`
|
|
504
|
+
# map; the snapshot path merges across shards.
|
|
505
|
+
def register_thread_labeled_counters(thread)
|
|
506
|
+
shard = {}
|
|
507
|
+
thread.thread_variable_set(@lc_thread_key, shard)
|
|
508
|
+
@hg_thread_mutex.synchronize { @thread_labeled_counters << shard }
|
|
509
|
+
shard
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
# 2.13-A — fold a per-thread HistogramAccumulator's contents into an
|
|
513
|
+
# already-snapshotted entry (`{buckets:, counts:, sum:, count:}`).
|
|
514
|
+
# Both arguments share the same `:buckets` so the bucket index axis
|
|
515
|
+
# is identical; we sum `counts` per bucket plus the scalars. Used
|
|
516
|
+
# when two threads observed the SAME (name, labels) pair — a
|
|
517
|
+
# legitimate steady state on a Rack route hit by concurrent
|
|
518
|
+
# workers.
|
|
519
|
+
def merge_histogram_snapshot!(existing, accum)
|
|
520
|
+
counts = existing[:counts]
|
|
521
|
+
acc_counts = accum.counts
|
|
522
|
+
i = 0
|
|
523
|
+
len = counts.length
|
|
524
|
+
while i < len
|
|
525
|
+
counts[i] += acc_counts[i]
|
|
526
|
+
i += 1
|
|
527
|
+
end
|
|
528
|
+
existing[:sum] += accum.sum
|
|
529
|
+
existing[:count] += accum.count
|
|
530
|
+
end
|
|
357
531
|
end
|
|
358
532
|
end
|
|
359
533
|
|
|
@@ -74,9 +74,22 @@ module Hyperion
|
|
|
74
74
|
hyperion_threadpool_queue_depth: {
|
|
75
75
|
help: 'In-flight count in the worker ThreadPool inbox (snapshot at scrape time)',
|
|
76
76
|
type: 'gauge'
|
|
77
|
+
},
|
|
78
|
+
# 2.12-E — per-worker request counter for the SO_REUSEPORT
|
|
79
|
+
# load-balancing audit. One series per worker (label_value =
|
|
80
|
+
# `Process.pid.to_s`); ticks on every dispatched request from
|
|
81
|
+
# every dispatch shape. Operators scrape /-/metrics N times in
|
|
82
|
+
# cluster mode to gather distribution across workers.
|
|
83
|
+
hyperion_requests_dispatch_total: {
|
|
84
|
+
help: 'Requests dispatched per worker (PID-labeled), across all dispatch modes',
|
|
85
|
+
type: 'counter'
|
|
77
86
|
}
|
|
78
87
|
}.freeze
|
|
79
88
|
|
|
89
|
+
# 2.12-E — name of the per-worker request counter family. Hoisted to
|
|
90
|
+
# a constant so the C-loop fold-in below stays declarative.
|
|
91
|
+
REQUESTS_DISPATCH_TOTAL = :hyperion_requests_dispatch_total
|
|
92
|
+
|
|
80
93
|
def render(stats)
|
|
81
94
|
buf = +''
|
|
82
95
|
grouped_status = {}
|
|
@@ -129,10 +142,72 @@ module Hyperion
|
|
|
129
142
|
buf << render(metrics_sink.snapshot)
|
|
130
143
|
buf << render_histograms(metrics_sink.histogram_snapshot)
|
|
131
144
|
buf << render_gauges(metrics_sink.gauge_snapshot)
|
|
132
|
-
|
|
145
|
+
labeled = merge_c_loop_into_dispatch_snapshot(metrics_sink.labeled_counter_snapshot)
|
|
146
|
+
buf << render_labeled_counters(labeled)
|
|
133
147
|
buf
|
|
134
148
|
end
|
|
135
149
|
|
|
150
|
+
# 2.12-E — merge `Hyperion::Http::PageCache.c_loop_requests_total`
|
|
151
|
+
# (process-global atomic ticked by the C accept4 + io_uring loops)
|
|
152
|
+
# into the `hyperion_requests_dispatch_total{worker_id=PID}` series
|
|
153
|
+
# for the current worker. Without this fold-in, a `-w 4` cluster
|
|
154
|
+
# serving from the C accept loop would scrape zeros from every
|
|
155
|
+
# worker even though the loop's atomic counter is ticking — the
|
|
156
|
+
# loop bypasses `Connection#serve`, so no Ruby-side
|
|
157
|
+
# `tick_worker_request` call ever lands.
|
|
158
|
+
#
|
|
159
|
+
# We only fold in when the labeled-counter family is ALREADY in
|
|
160
|
+
# the snapshot — i.e., something on the Ruby side has called
|
|
161
|
+
# `Metrics#tick_worker_request` at least once on this sink. The
|
|
162
|
+
# `Server#run_c_accept_loop` boot path performs a single
|
|
163
|
+
# registration tick on the runtime's metrics sink so this
|
|
164
|
+
# condition holds for any production cluster engaging the C loop.
|
|
165
|
+
#
|
|
166
|
+
# Why the gate matters: spec fixtures that `Hyperion::Metrics.new`
|
|
167
|
+
# to assert "empty body when no metrics have been recorded" would
|
|
168
|
+
# otherwise pull in a process-global atomic bumped by an earlier
|
|
169
|
+
# spec's C-loop run. Those fixtures never register the family, so
|
|
170
|
+
# the fold-in skips them cleanly.
|
|
171
|
+
#
|
|
172
|
+
# Idempotent on snapshots that already contain a series for the
|
|
173
|
+
# current PID. Pure on the input — we build a shallow copy of the
|
|
174
|
+
# snapshot so the live Hash behind `Metrics#labeled_counter_snapshot`
|
|
175
|
+
# isn't mutated.
|
|
176
|
+
#
|
|
177
|
+
# Defensive: when the C ext isn't loaded (JRuby / TruffleRuby) we
|
|
178
|
+
# silently skip — the snapshot stays Ruby-only.
|
|
179
|
+
def merge_c_loop_into_dispatch_snapshot(snap)
|
|
180
|
+
family = snap[REQUESTS_DISPATCH_TOTAL]
|
|
181
|
+
return snap if family.nil?
|
|
182
|
+
|
|
183
|
+
c_loop_count = c_loop_requests_total
|
|
184
|
+
return snap if c_loop_count <= 0
|
|
185
|
+
|
|
186
|
+
pid_label = Process.pid.to_s
|
|
187
|
+
merged_series = family[:series].dup
|
|
188
|
+
key = [pid_label].freeze
|
|
189
|
+
existing_key = merged_series.keys.find { |k| k.first == pid_label } || key
|
|
190
|
+
merged_series[existing_key] = (merged_series[existing_key] || 0) + c_loop_count
|
|
191
|
+
|
|
192
|
+
merged = snap.dup
|
|
193
|
+
merged[REQUESTS_DISPATCH_TOTAL] = {
|
|
194
|
+
meta: family[:meta] || { label_keys: %w[worker_id].freeze },
|
|
195
|
+
series: merged_series
|
|
196
|
+
}
|
|
197
|
+
merged
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def c_loop_requests_total
|
|
201
|
+
return 0 unless defined?(::Hyperion::Http::PageCache)
|
|
202
|
+
return 0 unless ::Hyperion::Http::PageCache.respond_to?(:c_loop_requests_total)
|
|
203
|
+
|
|
204
|
+
::Hyperion::Http::PageCache.c_loop_requests_total.to_i
|
|
205
|
+
rescue StandardError
|
|
206
|
+
# The scrape path must never raise — observability code degrades
|
|
207
|
+
# to "no fold-in" rather than failing the metrics endpoint.
|
|
208
|
+
0
|
|
209
|
+
end
|
|
210
|
+
|
|
136
211
|
def render_histograms(snap)
|
|
137
212
|
buf = +''
|
|
138
213
|
snap.each do |name, payload|
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hyperion
|
|
4
|
+
class Server
|
|
5
|
+
# 2.12-C — Connection lifecycle in C.
|
|
6
|
+
#
|
|
7
|
+
# Engaged by `Server#start_raw_loop` when ALL of the following hold:
|
|
8
|
+
#
|
|
9
|
+
# * The listener is plain TCP (no TLS, no h2 ALPN dance).
|
|
10
|
+
# * The route table has at least one `RouteTable::StaticEntry`
|
|
11
|
+
# registration (i.e. `Server.handle_static` was called).
|
|
12
|
+
# * The route table has NO non-StaticEntry registrations
|
|
13
|
+
# (any `Server.handle(:GET, '/api', dynamic_handler)` disables
|
|
14
|
+
# the C path; the C loop only knows how to write prebuilt
|
|
15
|
+
# responses).
|
|
16
|
+
# * The `HYPERION_C_ACCEPT_LOOP` env knob is not set to `"0"` /
|
|
17
|
+
# `"off"` (operator escape hatch for debug).
|
|
18
|
+
#
|
|
19
|
+
# On engage, the Ruby accept loop is *not* run for this listener;
|
|
20
|
+
# `Hyperion::Http::PageCache.run_static_accept_loop` drives the
|
|
21
|
+
# accept-and-serve loop entirely in C and only re-enters Ruby for:
|
|
22
|
+
#
|
|
23
|
+
# 1. Per-request lifecycle hooks
|
|
24
|
+
# (`Runtime#fire_request_start` / `fire_request_end`), gated
|
|
25
|
+
# by a single C-side integer flag so the no-hook hot path
|
|
26
|
+
# stays one syscall.
|
|
27
|
+
# 2. Connection handoff: requests that don't match a `StaticEntry`
|
|
28
|
+
# (or are malformed, h2/upgrade, or carry a body) are passed
|
|
29
|
+
# back as `(fd, partial_buffer)` — Ruby resumes ownership of
|
|
30
|
+
# the fd and dispatches via the regular `Connection` path.
|
|
31
|
+
#
|
|
32
|
+
# The wiring lives in this module so the conditional logic stays
|
|
33
|
+
# out of the Server hot-path entry methods.
|
|
34
|
+
module ConnectionLoop
|
|
35
|
+
module_function
|
|
36
|
+
|
|
37
|
+
# Whether the C accept loop is available and the env didn't
|
|
38
|
+
# disable it.
|
|
39
|
+
def available?
|
|
40
|
+
return false unless defined?(::Hyperion::Http::PageCache)
|
|
41
|
+
return false unless ::Hyperion::Http::PageCache.respond_to?(:run_static_accept_loop)
|
|
42
|
+
|
|
43
|
+
env = ENV['HYPERION_C_ACCEPT_LOOP']
|
|
44
|
+
env.nil? || !%w[0 off false no].include?(env.downcase)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# 2.12-D — whether to engage the io_uring accept loop variant
|
|
48
|
+
# over the 2.12-C `accept4` loop. All four conditions must hold:
|
|
49
|
+
#
|
|
50
|
+
# 1. Operator opted in via `HYPERION_IO_URING_ACCEPT=1`. This
|
|
51
|
+
# is OFF by default for 2.12.0 — flipping the default to ON
|
|
52
|
+
# is a 2.13 decision after production-soak.
|
|
53
|
+
# 2. The C ext was compiled with `HAVE_LIBURING` (probed at
|
|
54
|
+
# gem-install time via `extconf.rb` — needs `liburing-dev`
|
|
55
|
+
# headers). Builds without it ship the stub method that
|
|
56
|
+
# returns `:unavailable` regardless of the env var.
|
|
57
|
+
# 3. `Hyperion::Http::PageCache.run_static_io_uring_loop` is
|
|
58
|
+
# defined (paranoia: the symbol always exists on builds
|
|
59
|
+
# that loaded the C ext, but the check keeps us from
|
|
60
|
+
# NameError'ing on partial installs).
|
|
61
|
+
# 4. A liburing runtime probe — opening a tiny ring with
|
|
62
|
+
# `io_uring_queue_init`. The probe lives inside the C
|
|
63
|
+
# method itself (`run_static_io_uring_loop` returns
|
|
64
|
+
# `:unavailable` if `io_uring_queue_init` fails); we
|
|
65
|
+
# don't pre-probe here because that would require holding
|
|
66
|
+
# a ring open across the eligibility check, and the
|
|
67
|
+
# penalty for "engaged but probe-fail at run time" is
|
|
68
|
+
# one cheap fall-through to the `accept4` path.
|
|
69
|
+
def io_uring_eligible?
|
|
70
|
+
return false unless available?
|
|
71
|
+
return false unless ::Hyperion::Http::PageCache.respond_to?(:run_static_io_uring_loop)
|
|
72
|
+
return false unless ::Hyperion::Http::PageCache.respond_to?(:io_uring_loop_compiled?) &&
|
|
73
|
+
::Hyperion::Http::PageCache.io_uring_loop_compiled?
|
|
74
|
+
|
|
75
|
+
env = ENV['HYPERION_IO_URING_ACCEPT']
|
|
76
|
+
return false unless env
|
|
77
|
+
|
|
78
|
+
%w[1 on true yes].include?(env.downcase)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Whether the route table is C-loop eligible: only `StaticEntry`
|
|
82
|
+
# handlers, at least one of them, no dynamic handlers anywhere.
|
|
83
|
+
def eligible_route_table?(route_table)
|
|
84
|
+
return false unless route_table
|
|
85
|
+
|
|
86
|
+
any_static = false
|
|
87
|
+
route_table.instance_variable_get(:@routes).each_value do |path_table|
|
|
88
|
+
path_table.each_value do |handler|
|
|
89
|
+
return false unless handler.is_a?(::Hyperion::Server::RouteTable::StaticEntry)
|
|
90
|
+
|
|
91
|
+
any_static = true
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
any_static
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Build a lifecycle callback that, when invoked from the C loop
|
|
98
|
+
# with `(method_str, path_str)`, fires the runtime's
|
|
99
|
+
# `fire_request_start` / `fire_request_end` hooks against a
|
|
100
|
+
# minimal `Hyperion::Request` value object. `env=nil` and the
|
|
101
|
+
# response slot carries the `:c_static` symbol (just a marker —
|
|
102
|
+
# the wire write already happened in C and we have no
|
|
103
|
+
# `[status, headers, body]` tuple to hand back).
|
|
104
|
+
#
|
|
105
|
+
# The proc captures `runtime` so multi-tenant deployments with
|
|
106
|
+
# per-Server runtimes route hooks to the right observer
|
|
107
|
+
# registry. Allocation cost: one Request per request when
|
|
108
|
+
# hooks are active. The C loop only invokes this callback when
|
|
109
|
+
# `lifecycle_active?` is true; the no-hook path pays nothing.
|
|
110
|
+
def build_lifecycle_callback(runtime)
|
|
111
|
+
lambda do |method_str, path_str|
|
|
112
|
+
request = ::Hyperion::Request.new(
|
|
113
|
+
method: method_str,
|
|
114
|
+
path: path_str,
|
|
115
|
+
query_string: nil,
|
|
116
|
+
http_version: 'HTTP/1.1',
|
|
117
|
+
headers: {},
|
|
118
|
+
body: nil
|
|
119
|
+
)
|
|
120
|
+
if runtime.has_request_hooks?
|
|
121
|
+
runtime.fire_request_start(request, nil)
|
|
122
|
+
runtime.fire_request_end(request, nil, :c_static, nil)
|
|
123
|
+
end
|
|
124
|
+
nil
|
|
125
|
+
rescue StandardError
|
|
126
|
+
# Hook errors are already swallowed inside `Runtime#fire_*`;
|
|
127
|
+
# this rescue catches Request allocation oddities so a
|
|
128
|
+
# misbehaving observer can't take down the C loop.
|
|
129
|
+
nil
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Build the handoff callback the C loop invokes when a
|
|
134
|
+
# connection's first request can't be served from the static
|
|
135
|
+
# cache. Receives `(fd, partial_buffer_or_nil)` — Ruby owns
|
|
136
|
+
# the fd from that point on. We wrap the fd in a `Socket`
|
|
137
|
+
# (so `apply_timeout` and the rest of the Connection path see
|
|
138
|
+
# the same surface they always see) and dispatch through the
|
|
139
|
+
# server's existing `dispatch_handed_off` helper.
|
|
140
|
+
def build_handoff_callback(server)
|
|
141
|
+
lambda do |fd, partial|
|
|
142
|
+
server.send(:dispatch_handed_off, fd, partial)
|
|
143
|
+
rescue StandardError => e
|
|
144
|
+
server.send(:runtime_logger).warn do
|
|
145
|
+
{ message: 'C loop handoff dispatch failed',
|
|
146
|
+
error: e.message, error_class: e.class.name }
|
|
147
|
+
end
|
|
148
|
+
# Always close the fd if dispatch raised — Ruby owns it.
|
|
149
|
+
begin
|
|
150
|
+
require 'socket'
|
|
151
|
+
::Socket.for_fd(fd).close
|
|
152
|
+
rescue StandardError
|
|
153
|
+
nil
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|