hyperion-rb 2.12.0 → 2.14.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.
@@ -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
@@ -162,6 +186,12 @@ module Hyperion
162
186
  end
163
187
 
164
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.
165
195
  def reset!
166
196
  @counters_mutex.synchronize do
167
197
  @thread_counters.each(&:clear)
@@ -171,6 +201,10 @@ module Hyperion
171
201
  @gauges.each_value(&:clear)
172
202
  @gauge_blocks.each_value(&:clear)
173
203
  end
204
+ @hg_thread_mutex.synchronize do
205
+ @thread_histograms.each(&:clear)
206
+ @thread_labeled_counters.each(&:clear)
207
+ end
174
208
  end
175
209
 
176
210
  # ---- 2.4-C histogram + gauge API ---------------------------------
@@ -197,27 +231,37 @@ module Hyperion
197
231
 
198
232
  # Observe `value` on a previously-registered histogram. `label_values`
199
233
  # MUST be supplied in the same order as `label_keys` at registration.
200
- # The hot path: one Hash lookup, one accumulator update under a mutex.
201
- # Allocation footprint per observe: zero on the cached-key path
202
- # (same labels seen before); one frozen Array on first observation
203
- # for a given label-set.
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.
204
246
  def observe_histogram(name, value, label_values = EMPTY_LABELS)
205
- @hg_mutex.synchronize do
206
- meta = @histograms_meta[name]
207
- return unless meta # silently skip unregistered observations
208
-
209
- family = @histograms[name]
210
- accum = family[label_values]
211
- unless accum
212
- accum = HistogramAccumulator.new(meta[:buckets])
213
- # Freeze the label tuple so future identical-content tuples
214
- # hash to the same bucket — but we keep the original ref
215
- # provided by the caller as the canonical key so subsequent
216
- # observes with the same Array bypass the freeze step.
217
- family[label_values.frozen? ? label_values : label_values.dup.freeze] = accum
218
- end
219
- accum.observe(value)
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
220
263
  end
264
+ accum.observe(value)
221
265
  end
222
266
 
223
267
  # Set a gauge value. `label_values` follows the same convention as
@@ -263,15 +307,40 @@ module Hyperion
263
307
 
264
308
  # Snapshot helpers — read-only views of the current histogram /
265
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).
266
317
  def histogram_snapshot
267
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).
268
324
  @hg_mutex.synchronize do
269
- @histograms.each do |name, family|
270
- per_labels = {}
271
- family.each { |labels, accum| per_labels[labels] = accum.snapshot }
272
- out[name] = { meta: @histograms_meta[name], series: per_labels }
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
273
341
  end
274
342
  end
343
+
275
344
  out
276
345
  end
277
346
 
@@ -309,19 +378,33 @@ module Hyperion
309
378
 
310
379
  # Labeled counter — separate from the legacy thread-local counter
311
380
  # surface (which is unlabeled and per-thread for hot-path
312
- # contention-free increments). Labeled counters take a mutex per
313
- # increment, but they're called from low-rate paths (per-conn
314
- # rejection ~ kHz worst case, vs M+req/s on the unlabeled side)
315
- # so the contention cost is invisible.
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.
316
389
  def increment_labeled_counter(name, label_values = EMPTY_LABELS, by = 1)
317
- @hg_mutex.synchronize do
318
- @labeled_counters_meta ||= {}
319
- @labeled_counters_meta[name] ||= { label_keys: [].freeze }
320
- @labeled_counters ||= {}
321
- family = (@labeled_counters[name] ||= {})
322
- key = label_values.frozen? ? label_values : label_values.dup.freeze
323
- family[key] = (family[key] || 0) + by
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
324
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
325
408
  end
326
409
 
327
410
  def register_labeled_counter(name, label_keys: [])
@@ -333,14 +416,27 @@ module Hyperion
333
416
  end
334
417
  end
335
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).
336
423
  def labeled_counter_snapshot
337
424
  out = {}
338
425
  @hg_mutex.synchronize do
339
- (@labeled_counters || {}).each do |name, family|
340
- per_labels = {}
341
- family.each { |labels, count| per_labels[labels] = count }
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|
342
434
  meta = (@labeled_counters_meta || {})[name] || { label_keys: [].freeze }
343
- out[name] = { meta: meta, series: per_labels }
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
344
440
  end
345
441
  end
346
442
  out
@@ -392,6 +488,46 @@ module Hyperion
392
488
  @counters_mutex.synchronize { @thread_counters << counters }
393
489
  counters
394
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
395
531
  end
396
532
  end
397
533
 
@@ -34,6 +34,92 @@ module Hyperion
34
34
  module ConnectionLoop
35
35
  module_function
36
36
 
37
+ # 2.14-B — bound applied to the wake-connect dial inside
38
+ # `Server#stop`. The listener is local — a successful connect
39
+ # is sub-millisecond — so the cap exists purely as a sanity
40
+ # bound for the pathological case where the listener was
41
+ # already torn down (Errno::ECONNREFUSED is fast) or the
42
+ # kernel netstack is somehow stuck (e.g. CI under heavy load).
43
+ WAKE_CONNECT_TIMEOUT_SECONDS = 1.0
44
+
45
+ # 2.14-B — number of wake-connect dials issued per `Server#stop`.
46
+ # In single-server / `:share` cluster mode (Darwin/BSD), one dial
47
+ # is enough — the listener is shared and any wake races to a
48
+ # parked accept call. In `:reuseport` cluster mode (Linux), the
49
+ # kernel hashes incoming SYNs across each worker's per-process
50
+ # listener fd; one dial may hash to a sibling whose stop hasn't
51
+ # progressed, leaving THIS worker's accept thread parked. K=8
52
+ # drops the miss probability to <1% for realistic worker counts
53
+ # (≤32 workers per host) and adds at most ~8ms to a stop call —
54
+ # well below the master-side `graceful_timeout` (30s default).
55
+ WAKE_CONNECT_BURST = 8
56
+
57
+ # 2.14-B — Wake any thread parked in `accept(2)` on the listener
58
+ # bound at `host:port` by dialing one (or `count`) throwaway TCP
59
+ # connections.
60
+ #
61
+ # Background. On Linux ≥ 6.x, calling `close()` on a listening
62
+ # socket from one thread does NOT interrupt another thread that
63
+ # is currently blocked in `accept(2)` on that same fd — the
64
+ # kernel silently dropped the close-wake guarantee that
65
+ # `Server#stop` (and 2.13-C's spec teardown) had relied on.
66
+ # Without this helper, the C accept loop stays parked until a
67
+ # real connection arrives, which during a SIGTERM-driven graceful
68
+ # shutdown means "until SIGKILL".
69
+ #
70
+ # The fix is structural: dial a throwaway TCP connection at the
71
+ # listener's bound address. The accept call returns with the new
72
+ # fd, the C loop services it (a 0-byte read drops it), then
73
+ # re-checks `hyp_cl_stop` between accepts and exits cleanly. The
74
+ # 2.13-C connection_loop_spec helper does the same thing in spec
75
+ # land — this is the production-side mirror.
76
+ #
77
+ # Burst semantics. With SO_REUSEPORT (Linux cluster mode), the
78
+ # kernel hashes each SYN to one of the N still-open per-worker
79
+ # listeners. A single dial from worker A may hash to worker B —
80
+ # leaving A's parked accept un-woken. Dialing K times (default
81
+ # `WAKE_CONNECT_BURST`) drives the miss probability down to
82
+ # negligible for typical worker counts.
83
+ #
84
+ # Failure-tolerant by construction:
85
+ # * `Errno::ECONNREFUSED` — listener already closed (the close
86
+ # raced ahead of us). Nothing to wake; bail out of the burst
87
+ # so we don't spend the timeout budget on doomed dials.
88
+ # * `Errno::EADDRNOTAVAIL` — interface gone. Same.
89
+ # * Connect timeout — kernel netstack is stuck; we tried, the
90
+ # caller's `thread.join(timeout)` will surface the symptom.
91
+ # * Any other socket error — log nothing (we may be running
92
+ # inside a signal handler thread); just swallow.
93
+ def wake_listener(host, port, connect_timeout: WAKE_CONNECT_TIMEOUT_SECONDS,
94
+ count: 1)
95
+ return unless host && port
96
+ return if count <= 0
97
+
98
+ count.times do
99
+ break unless dial_wake_once(host, port, connect_timeout)
100
+ end
101
+ nil
102
+ end
103
+
104
+ # 2.14-B — single dial. Returns true on success (continue
105
+ # bursting), false on a "listener gone" outcome (abort the burst
106
+ # so we don't waste the timeout budget on N×ECONNREFUSED).
107
+ def dial_wake_once(host, port, connect_timeout)
108
+ ::Socket.tcp(host, port, connect_timeout: connect_timeout, &:close)
109
+ true
110
+ rescue Errno::ECONNREFUSED, Errno::EADDRNOTAVAIL, Errno::EHOSTUNREACH,
111
+ Errno::ENETUNREACH
112
+ # Listener gone — no point retrying, the kernel will refuse
113
+ # every dial in this burst the same way.
114
+ false
115
+ rescue Errno::ETIMEDOUT, Errno::ECONNRESET, Errno::EPIPE,
116
+ Errno::EBADF, IOError, SocketError
117
+ # Transient — keep bursting in case a later dial races into a
118
+ # still-open sibling listener (REUSEPORT cluster mode).
119
+ true
120
+ end
121
+ private_class_method :dial_wake_once
122
+
37
123
  # Whether the C accept loop is available and the env didn't
38
124
  # disable it.
39
125
  def available?
@@ -78,20 +164,32 @@ module Hyperion
78
164
  %w[1 on true yes].include?(env.downcase)
79
165
  end
80
166
 
81
- # Whether the route table is C-loop eligible: only `StaticEntry`
82
- # handlers, at least one of them, no dynamic handlers anywhere.
167
+ # Whether the route table is C-loop eligible: every registered
168
+ # entry is either a `StaticEntry` (2.12-C path) or a
169
+ # `DynamicBlockEntry` (2.14-A path), and the table has at least
170
+ # one of either. Legacy `Server.handle(method, path, handler)`
171
+ # registrations (where `handler` takes a `Hyperion::Request`)
172
+ # disable the C path — those still flow through `Connection#serve`.
83
173
  def eligible_route_table?(route_table)
84
174
  return false unless route_table
85
175
 
86
- any_static = false
176
+ any_eligible = false
87
177
  route_table.instance_variable_get(:@routes).each_value do |path_table|
88
178
  path_table.each_value do |handler|
89
- return false unless handler.is_a?(::Hyperion::Server::RouteTable::StaticEntry)
179
+ return false unless eligible_entry?(handler)
90
180
 
91
- any_static = true
181
+ any_eligible = true
92
182
  end
93
183
  end
94
- any_static
184
+ any_eligible
185
+ end
186
+
187
+ # 2.14-A — predicate split out so specs and the engagement check
188
+ # can introspect single entries. Lives here (rather than on the
189
+ # entry classes) so the eligibility surface stays in one place.
190
+ def eligible_entry?(handler)
191
+ handler.is_a?(::Hyperion::Server::RouteTable::StaticEntry) ||
192
+ handler.is_a?(::Hyperion::Server::RouteTable::DynamicBlockEntry)
95
193
  end
96
194
 
97
195
  # Build a lifecycle callback that, when invoked from the C loop
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'stringio'
4
+
3
5
  module Hyperion
4
6
  class Server
5
7
  # 2.10-D — direct-dispatch route registry. Mirrors agoo's
@@ -86,6 +88,68 @@ module Hyperion
86
88
  end
87
89
  end
88
90
 
91
+ # 2.14-A — wrapper for a Rack-style block registered via
92
+ # `Server.handle(:GET, '/path') { |env| [...] }`. Differs from
93
+ # `StaticEntry` in that the response is computed per-request
94
+ # rather than baked at registration time — but the route table
95
+ # entry shape is uniform, so the C accept loop can branch on
96
+ # `is_a?(DynamicBlockEntry)` AFTER the StaticEntry check and
97
+ # invoke the block via the registered C-loop dispatch helper.
98
+ #
99
+ # The struct holds:
100
+ # * `method` — request-method symbol (`:GET`, `:POST`, ...)
101
+ # * `path` — exact-match path String (frozen)
102
+ # * `block` — the registered Proc / lambda; receives a Rack
103
+ # env hash and must return a `[status, headers, body]`
104
+ # triple per the Rack spec. The C accept loop hands it a
105
+ # populated env via the `Adapter::Rack.dispatch_for_c_loop`
106
+ # helper; the block sees the same env shape Rack apps
107
+ # normally see (HTTP_*, REQUEST_METHOD, PATH_INFO, etc.).
108
+ #
109
+ # Calling the entry directly (the legacy fall-through path used
110
+ # when the C accept loop is NOT engaged — TLS listeners, mixed
111
+ # tables, operator escape hatch via `HYPERION_C_ACCEPT_LOOP=0`)
112
+ # delegates straight to the block with a freshly-built env via
113
+ # the existing `Adapter::Rack#call` machinery. The Connection
114
+ # path's direct-route dispatcher already handles
115
+ # `respond_to?(:call)` entries by invoking them with a
116
+ # `Hyperion::Request` value object — we route through that
117
+ # surface so the legacy fallback stays bit-identical to a
118
+ # 2.13-shape `Server.handle` registration.
119
+ DynamicBlockEntry = Struct.new(:method, :path, :block) do
120
+ # Legacy direct-route surface: `RouteTable#lookup` → handler →
121
+ # `handler.call(request)` returning a `[status, headers, body]`
122
+ # triple. Used by the Connection path when the C accept loop is
123
+ # disengaged (TLS, mixed tables). We hand the block a minimal
124
+ # env hash so it sees the same Rack-style API regardless of
125
+ # which dispatch shape served the request.
126
+ def call(request)
127
+ env = build_legacy_env(request)
128
+ block.call(env)
129
+ end
130
+
131
+ private
132
+
133
+ def build_legacy_env(request)
134
+ headers = request.respond_to?(:headers) ? (request.headers || {}) : {}
135
+ env = {
136
+ 'REQUEST_METHOD' => request.method,
137
+ 'PATH_INFO' => request.path,
138
+ 'QUERY_STRING' => request.query_string.to_s,
139
+ 'SERVER_NAME' => 'localhost',
140
+ 'SERVER_PORT' => '80',
141
+ 'rack.input' => StringIO.new(request.body.to_s),
142
+ 'rack.errors' => $stderr,
143
+ 'rack.url_scheme' => 'http'
144
+ }
145
+ headers.each do |name, value|
146
+ key = "HTTP_#{name.to_s.upcase.tr('-', '_')}"
147
+ env[key] = value
148
+ end
149
+ env
150
+ end
151
+ end
152
+
89
153
  def initialize
90
154
  # Per-method Hash so the lookup is `@routes[:GET][path]`
91
155
  # — two integer-keyed-Hash hits. Pre-allocate the seven