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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +32 -0
- data/README.md +133 -48
- data/lib/quonfig/client.rb +560 -66
- data/lib/quonfig/config_loader.rb +29 -5
- data/lib/quonfig/sse_config_client.rb +9 -0
- data/lib/quonfig/telemetry/failover_aggregator.rb +4 -2
- data/lib/quonfig/telemetry/telemetry_reporter.rb +51 -0
- data/lib/quonfig/version.rb +1 -1
- metadata +2 -2
|
@@ -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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
|
49
|
-
#
|
|
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
|
data/lib/quonfig/version.rb
CHANGED
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.
|
|
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-
|
|
11
|
+
date: 2026-09-11 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|