hyperion-rb 2.12.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.
@@ -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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hyperion
4
- VERSION = '2.12.0'
4
+ VERSION = '2.13.0'
5
5
  end
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: 2.12.0
4
+ version: 2.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrey Lobanov