quonfig 1.3.0 → 1.4.1

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.
@@ -31,6 +31,12 @@ module Quonfig
31
31
  # SSE does not change it).
32
32
  attr_reader :held_generation, :install_count
33
33
 
34
+ # Repoint the failover-telemetry sink. The one caller is
35
+ # Client#after_fork_in_child: a forked child gets fresh aggregators (the
36
+ # parent still owns and flushes its own), and the loader has to record
37
+ # into the child's copy rather than the inherited one (qfg-lv4n.1).
38
+ attr_writer :failover_aggregator
39
+
34
40
  # +store+: the Quonfig::ConfigStore to populate on successful fetch.
35
41
  # +options+: a Quonfig::Options instance (supplies sdk_key + config_api_urls).
36
42
  # +logger+: optional logger override (defaults to module LOG).
@@ -386,11 +392,29 @@ module Quonfig
386
392
  # ordering info, so it is never rejected as "older"; freezing the client
387
393
  # on stale config would be worse (mirrors sdk-node).
388
394
  unless @held_generation.nil? || incoming_gen <= 0 || incoming_gen > @held_generation
389
- @logger.debug "Reject-older guard: dropping incoming generation #{incoming_gen} <= held #{@held_generation} (source=#{source})"
390
- # Failover observability (qfg-41nh.18): count the guard rejection. This
391
- # single guard covers BOTH the HTTP config-fetch path and the SSE
392
- # message path (apply_envelope) every network install funnels here.
393
- @failover_aggregator&.record_guard_rejected
395
+ if incoming_gen < @held_generation
396
+ @logger.debug "Reject-older guard: dropping incoming generation #{incoming_gen} < held #{@held_generation} (source=#{source})"
397
+ # Failover observability (qfg-41nh.18): count the guard rejection.
398
+ # This single guard covers BOTH the HTTP config-fetch path and the
399
+ # SSE message path (apply_envelope) — every network install funnels
400
+ # here. Only a STRICTLY older payload is counted (qfg-rr5b): that is
401
+ # the one thing `guardRejected` is meant to report, "a leg tried to
402
+ # move us backwards", which is what the sdk_failover dashboard panel
403
+ # is for.
404
+ @failover_aggregator&.record_guard_rejected
405
+ else
406
+ # Equal generation: a re-delivery of the envelope we already hold, and
407
+ # a silent no-op (qfg-rr5b). Two server behaviors produce it in normal
408
+ # steady state — api-delivery's SSE `sendInitialConfig` re-sends the
409
+ # current envelope on every connect regardless of the Last-Event-Id
410
+ # we send, and a config poll on an empty per-leg ETag slot (a
411
+ # fresh transport, a reconnect, the fallback poller's engage fetch)
412
+ # returns a full 200 at the same generation. Counting those as
413
+ # `guardRejected` made a healthy client report failover activity from
414
+ # init alone. Not installed, not counted — but still :not_modified, so
415
+ # the caller's liveness stamp is unchanged.
416
+ @logger.debug "Same-generation re-delivery: ignoring incoming generation #{incoming_gen} (source=#{source})"
417
+ end
394
418
  return :not_modified
395
419
  end
396
420
 
@@ -129,6 +129,15 @@ module Quonfig
129
129
  @worker = Thread.new { run_loop(&on_envelope) }
130
130
  end
131
131
 
132
+ # True while the worker thread that owns the stream (and its reconnect
133
+ # loop) is running. Stays true across a reconnect — the worker owns the
134
+ # retry, so a blip is not "no channel". Used by Client#connection_state
135
+ # so that diagnostic derives from liveness rather than a stored flag
136
+ # (qfg-lv4n.1).
137
+ def alive?
138
+ @worker&.alive? || false
139
+ end
140
+
132
141
  # Shut down. Interrupts the in-flight stream by closing the underlying
133
142
  # socket from this thread — the worker thread observes the resulting
134
143
  # IOError, sees @stopped == true, and exits cleanly.
@@ -45,8 +45,10 @@ module Quonfig
45
45
  end
46
46
  end
47
47
 
48
- # Count one install dropped by the reject-older ordering guard (an
49
- # equal-or-older snapshot on any install path HTTP config-fetch or SSE).
48
+ # Count one install dropped by the reject-older ordering guard because it
49
+ # was STRICTLY older than the held generation, on any install path (HTTP
50
+ # config-fetch or SSE). An equal-generation re-delivery is dropped too but
51
+ # is not counted (qfg-rr5b).
50
52
  def record_guard_rejected
51
53
  @mutex.synchronize do
52
54
  @start_at_ms ||= Quonfig::TimeHelpers.now_in_ms
@@ -51,6 +51,9 @@ module Quonfig
51
51
  @stopped = Concurrent::AtomicBoolean.new(false)
52
52
  @thread = nil
53
53
  @at_exit_registered = false
54
+ # Set on #start. Everything that can EMIT is gated on it so a forked
55
+ # child never speaks for the process that created this reporter.
56
+ @owner_pid = nil
54
57
  end
55
58
 
56
59
  def enabled?
@@ -80,6 +83,11 @@ module Quonfig
80
83
  return if @thread&.alive?
81
84
  return unless enabled?
82
85
 
86
+ # Claim ownership for THIS process. fork(2) copies the reporter, its
87
+ # aggregators, and the process-wide at_exit closure registered below;
88
+ # the pid recorded here is what lets the copy know it is not the
89
+ # owner and must stay silent (qfg-lv4n.1, dd-trace-rb's pattern).
90
+ @owner_pid = Process.pid
83
91
  @stopped.make_false
84
92
  register_at_exit_handler
85
93
  @thread = Thread.new do
@@ -106,6 +114,8 @@ module Quonfig
106
114
  end
107
115
 
108
116
  def stop
117
+ return if foreign_process?('stop')
118
+
109
119
  @stopped.make_true
110
120
  thread = @thread
111
121
  @thread = nil
@@ -121,7 +131,13 @@ module Quonfig
121
131
 
122
132
  # Drain all aggregators and POST the batch. Public so tests can
123
133
  # trigger a sync without waiting for the background loop.
134
+ #
135
+ # Silent in any process other than the one that started the reporter:
136
+ # after a fork the child holds a full copy of the PARENT's un-flushed
137
+ # window, and the parent is still going to flush it itself.
124
138
  def sync
139
+ return if foreign_process?('sync')
140
+
125
141
  events = []
126
142
  if (summaries_event = @evaluation_summaries_aggregator&.drain_event)
127
143
  events << summaries_event
@@ -151,8 +167,41 @@ module Quonfig
151
167
  @at_exit_registered
152
168
  end
153
169
 
170
+ # Pid of the process that started this reporter, or nil if it was never
171
+ # started. Visible for tests / diagnostics.
172
+ attr_reader :owner_pid
173
+
174
+ # Called on the INHERITED reporter in a forked child, from
175
+ # +Quonfig::Client#after_fork_in_child+, once the child has dropped its
176
+ # reference to it. Makes the copied window unreachable so nothing can
177
+ # ever emit it — belt to the +@owner_pid+ braces.
178
+ #
179
+ # Deliberately does NOT stop, close, or join anything: the thread does
180
+ # not exist in the child, and the HTTP connection's fd is shared with
181
+ # the parent.
182
+ def discard_inherited!
183
+ @stopped.make_true
184
+ @thread = nil
185
+ @context_shape_aggregator = nil
186
+ @example_contexts_aggregator = nil
187
+ @evaluation_summaries_aggregator = nil
188
+ @failover_aggregator = nil
189
+ end
190
+
154
191
  private
155
192
 
193
+ # True when this reporter belongs to a different process — i.e. we are
194
+ # a fork(2) copy. Never true before #start (nothing has been claimed,
195
+ # and nothing was registered at_exit either).
196
+ def foreign_process?(operation)
197
+ return false if @owner_pid.nil?
198
+ return false if @owner_pid == Process.pid
199
+
200
+ LOG.debug "[quonfig] Telemetry #{operation} skipped in forked child " \
201
+ "pid=#{Process.pid} owner_pid=#{@owner_pid}"
202
+ true
203
+ end
204
+
156
205
  # Rails / Passenger / Puma workers often terminate via SIGTERM without
157
206
  # a chance to call Client#stop. Register a Kernel.at_exit hook on
158
207
  # first start so the in-flight batch still gets flushed.
@@ -174,6 +223,8 @@ module Quonfig
174
223
  # no-op. Bounded so a stuck reporter thread or dead telemetry
175
224
  # endpoint can't hang process exit.
176
225
  def final_drain_on_exit
226
+ return if foreign_process?('at_exit drain')
227
+
177
228
  @stopped.make_true
178
229
  thread = @thread
179
230
  @thread = nil
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Quonfig
4
- VERSION = '1.3.0'
4
+ VERSION = '1.4.1'
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: quonfig
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.0
4
+ version: 1.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jeff Dwyer
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-18 00:00:00.000000000 Z
11
+ date: 2026-09-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport