quonfig 1.0.0 → 1.1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4a4a3747c89f6d3eff1e1708f103c4b131784bb39826be73df5756bf2c52582a
4
- data.tar.gz: 2346cd0fb9d75dc3bcbd382ffa312bfa1b67ae2fb6cb57321bf964f40aebc089
3
+ metadata.gz: df149576c34f45d691f7252a2f0e05b2c02b95681ab9986fac6a373071210c7e
4
+ data.tar.gz: ab5dc409a48ed4f471b500d62451a8d1b3b1e7de67871172ce684327ba7a1777
5
5
  SHA512:
6
- metadata.gz: 662491807b3ba2c9267c96b5cfbc9f9243e7fe0f096707710a354d27df339a5e058889d41dca1d5d4268b3bdac5d62db7b3f3577478343e70366091c198e61ad
7
- data.tar.gz: f5d9141986179a7b184ef638e541d62fd575683f2c42b42387c87c9facacaaab0ec25fd7c50260310d74872f11edee11ef136597503b701dc1bea3c705a444d6
6
+ metadata.gz: dec2b311edf9f515e00803d7f2a60c9f10897807779b26a44f4aa842ef5ac46239a432a194e72378eb23c3e03a79002ff3332e6d72be265683b707adb88134ed
7
+ data.tar.gz: 55c3a892dfa3af12e4db273e09faf5f0b96c8c2861cdfdb611c9ba75877a6e3a1c696568593b0cdd6283874b072f259738cc14c8d3a871a432cfbc17a194df35
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.1.1 - 2026-07-03
4
+
5
+ - **Fix (SSE): the stream leg is pinned to the primary stream URL — SSE never fails over (qfg-41nh.6).** The reconnect loop previously rotated through `sse_api_urls` on every reconnect, so with the default two derived stream URLs a primary blip silently parked the live stream on `stream.secondary` and bound config freshness to the mirror. The stream now always dials `sse_api_urls[0]` and retries it forever with backoff (matching sdk-go); failover remains HTTP-poll only, and the HTTP config-fetch hedge still uses both `api_urls` legs. The `sse_failed_over_to_secondary?` diagnostic is now latched at the dial site, and the failover chaos harness hands the client both stream legs so the f05 probe actually exercises the pin.
6
+ - **Fix (delivery): per-leg config-fetch aborts are now wall-clock (qfg-41nh.6).** The per-leg timeout was enforced with Faraday/Net::HTTP phase timeouts, which are per-read — a slow-drip upstream (one byte per interval) could hold a "bounded" leg open indefinitely and wedge the init fetch, a fallback poll tick, or the hedge drain. `timeout_ms` (plus a small headroom) is now also enforced as a wall-clock ceiling over the whole request, surfacing a `Faraday::TimeoutError` through the existing failure paths. Hedged legs additionally always settle their drain slot, so `fetch!` is guaranteed to return within the per-leg abort budget.
7
+ - **Fix (client): liveness signals no longer lie during outages (qfg-41nh.6).** The fallback poll worker stamped `last_successful_refresh` and fired `on_update` on every tick regardless of fetch outcome, so a total outage with `on_init_failure: :return` reported `ready? == true` over an empty store. The worker now stamps freshness only on successful fetches (`:updated` / `:not_modified`; a 304 or guard-rejected-but-successful response counts) and fires `on_update` only when an envelope is actually installed, matching sdk-go.
8
+ - **Fix (client): the fallback poller fetches immediately on engage and logs the engage edge (qfg-41nh.6).** The poll worker previously slept a full interval before its first fetch, so the first post-engage data arrived ~180s after SSE loss (default 60s interval) vs ~120s in sdk-go; it now fetches first, then sleeps. Engaging Layer 2 also logs at WARN (previously silent — only the stop path logged, at debug), matching sdk-go's operator signal.
9
+
10
+ ## 1.1.0 - 2026-07-01
11
+
12
+ - **Feat (delivery): the HTTP config-fetch is now a parallel-failover hedge (qfg-7h5d.1.14).** On every init/refresh config fetch the SDK fires the **primary** `api_urls` leg first; if it answers within `config_fetch_hedge_delay_ms` it **wins and the secondary is never contacted** (cold standby — a healthy system adds zero secondary load). If the primary is slow past the hedge delay **or** errors fast, the SDK **also** fires the secondary **in parallel** without cancelling the primary. Whatever arrives is installed through the existing reject-older guard, so watermark-max falls out for free: the higher generation wins, a late older payload never regresses an established client, and a late newer payload heals forward. Readiness latches on the first successful install; a late-but-newer leg heals forward afterward. SSE is untouched — only the HTTP config-fetch path hedges. Both legs failing preserves the existing init-failure semantics (`on_init_failure`).
13
+ - **Feat (options): two additive hedge knobs.** `config_fetch_hedge_delay_ms` (default `2000`) is how long the hedge waits for the primary before also firing the secondary in parallel. `config_fetch_hedge_abort_ms` (default `6000`) is the per-leg hard-abort deadline on the hedged path; it must exceed the longest healable primary latency so a late-but-newer primary heals forward, and must be below `init_timeout_ms` so the init-path heal leg is not clipped (the client logs a one-time warning at construction if `init_timeout_ms <= config_fetch_hedge_abort_ms` with a secondary leg configured). The existing `config_fetch_timeout_ms` is unchanged and still governs the sequential / single-URL fetch path.
14
+ - **Backward-compatible behavioral notes (additive minor).** (1) In a fast-both topology where both legs answer well inside the hedge delay, `resolved_from` may now report `"primary"` where 1.0.0's sequential fetch could report `"secondary"` — a fast healthy primary now always wins. (2) On a heal-forward (a late newer leg landing after readiness latched), an **extra** post-ready `on_update` config-update callback may fire as the client converges on the higher generation. (3) ETags are now tracked **per leg** rather than as a single shared value, so a 304 from one leg can no longer mask the other and the two concurrent legs no longer race on a shared ETag.
15
+ - **Install-guard carve-out for unversioned snapshots:** a delivery payload whose `generation` is absent or `<= 0` (e.g. from a server that predates the generation watermark) is installed by an established client rather than rejected as older. Defensive back-compat guard — with servers that emit true generations it never triggers.
16
+
3
17
  ## 1.0.0 - 2026-06-06
4
18
 
5
19
  - **Stable 1.0.0 release.** The Quonfig Ruby SDK is now declared stable. No API or
@@ -409,6 +409,48 @@ module Quonfig
409
409
  end
410
410
  end
411
411
 
412
+ # ---- Failover + canonical-ordering diagnostics (qfg-7h5d.1.9) ------
413
+ #
414
+ # Read-only signals surfaced for the failover/ordering chaos probe and for
415
+ # operators. Like #connection_state / #last_successful_refresh these are
416
+ # DIAGNOSTIC ONLY — do not wire them into a liveness probe.
417
+
418
+ # True once the SDK has installed at least one envelope (any source). The
419
+ # failover scenarios assert the client reaches readiness off the secondary
420
+ # leg inside the init budget even when the primary is refused/hung/slow.
421
+ def ready?
422
+ !last_successful_refresh.nil?
423
+ end
424
+
425
+ # Meta.generation of the currently-held envelope (0 before the first install
426
+ # or when the backend does not emit a generation). Canonical ordering: an
427
+ # established client never regresses to a lower generation.
428
+ def held_generation
429
+ @config_loader&.held_generation || 0
430
+ end
431
+
432
+ # Count of envelopes actually installed. Rejected-older and same-generation
433
+ # snapshots do NOT bump this, so o04 can assert "no flap" via a stable count.
434
+ def config_install_count
435
+ @config_loader&.install_count || 0
436
+ end
437
+
438
+ # 'primary' / 'secondary' / '' — which config_api_urls leg produced the
439
+ # currently-held config. Used to assert HTTP config-fetch failover (f01-f04).
440
+ def resolved_from
441
+ @config_loader&.resolved_from || ''
442
+ end
443
+
444
+ # True if the live SSE stream has ever repointed to a non-primary leg. The
445
+ # failover epic asserts this stays false (f05): SSE does not fail over.
446
+ def sse_failed_over_to_secondary?
447
+ sse = @sse_client
448
+ return false if sse.nil?
449
+ return false unless sse.respond_to?(:failed_over_to_secondary?)
450
+
451
+ sse.failed_over_to_secondary?
452
+ end
453
+
412
454
  def fork
413
455
  self.class.new(@options.for_fork)
414
456
  end
@@ -770,6 +812,7 @@ module Quonfig
770
812
  raise Quonfig::Errors::InvalidSdkKeyError, @options.sdk_key if @options.sdk_key.nil? || @options.sdk_key.to_s.strip.empty?
771
813
 
772
814
  warn_if_pin_ignored_in_delivery_mode
815
+ warn_if_hedge_abort_exceeds_init_timeout
773
816
 
774
817
  @config_loader = Quonfig::ConfigLoader.new(@store, @options)
775
818
 
@@ -847,6 +890,30 @@ module Quonfig
847
890
  )
848
891
  end
849
892
 
893
+ # qfg-7h5d.1.14: the per-leg hedge abort MUST be < init_timeout_ms, otherwise
894
+ # the init-path heal leg is clipped by the overall init deadline before it can
895
+ # heal forward. Mirrors sdk-go's construction-time warning in options.go. Warn
896
+ # once at init in delivery mode; does not change behavior.
897
+ def warn_if_hedge_abort_exceeds_init_timeout
898
+ return unless @options.respond_to?(:config_fetch_hedge_abort_ms)
899
+ # The hedge (and its heal leg) only engages with a secondary leg; with a
900
+ # single config_api_url there is no heal leg to clip, so the warning would
901
+ # be misleading.
902
+ return unless Array(@options.config_api_urls).length >= 2
903
+
904
+ abort_ms = @options.config_fetch_hedge_abort_ms
905
+ init_ms = @options.init_timeout_ms
906
+ return if abort_ms.nil? || init_ms.nil?
907
+ return if init_ms > abort_ms
908
+
909
+ LOG.warn(
910
+ "[quonfig] init_timeout_ms (#{init_ms}ms) <= config_fetch_hedge_abort_ms " \
911
+ "(#{abort_ms}ms); the hedged config-fetch heal leg may be clipped by the " \
912
+ 'init deadline before it can heal forward. Set init_timeout_ms above the ' \
913
+ 'hedge abort.'
914
+ )
915
+ end
916
+
850
917
  def handle_init_failure(err)
851
918
  if @options.on_init_failure == Quonfig::Options::ON_INITIALIZATION_FAILURE::RETURN
852
919
  LOG.warn "[quonfig] Initialization did not complete cleanly; continuing with empty store: #{err.message}"
@@ -903,18 +970,35 @@ module Quonfig
903
970
 
904
971
  poll_seconds = poll_ms / 1000.0
905
972
  stopped_ref = -> { @stopped }
973
+ # Fetch-first (qfg-41nh.6): the poller fetches IMMEDIATELY on engage and
974
+ # then sleeps the interval — matching sdk-go's fallback_poller.go
975
+ # engage() (immediate Fetch, then ticker). A sleep-first worker made the
976
+ # first post-engage data arrive a full extra interval late (~180s after
977
+ # SSE loss with the default 60s interval, vs ~120s in go).
906
978
  worker = lambda do |notify_delivered|
907
979
  loop do
908
980
  break if stopped_ref.call
909
981
 
910
- sleep poll_seconds
982
+ result = @config_loader.fetch!
983
+ # Liveness honesty (qfg-41nh.6): stamp freshness only when the fetch
984
+ # actually SUCCEEDED. :updated and :not_modified (a 304, or a
985
+ # guard-rejected-but-successful response) are successes — the
986
+ # channel is healthy; :failed must NOT advance
987
+ # last_successful_refresh (pre-fix every tick stamped + notified
988
+ # regardless of outcome, so ready?/freshness reported healthy over
989
+ # an empty store through a total outage). on_update fires only on a
990
+ # real install, matching sdk-go (OnConfigUpdate fires in
991
+ # installEnvelope only).
992
+ if result != :failed
993
+ sync_evaluator_env_id!
994
+ record_refresh!
995
+ notify_delivered.call
996
+ end
997
+ notify_on_update_callback if result == :updated
998
+
911
999
  break if stopped_ref.call
912
1000
 
913
- @config_loader.fetch!
914
- sync_evaluator_env_id!
915
- record_refresh!
916
- notify_delivered.call
917
- notify_on_update_callback
1001
+ sleep poll_seconds
918
1002
  end
919
1003
  end
920
1004
 
@@ -923,6 +1007,10 @@ module Quonfig
923
1007
  )
924
1008
  @state_mutex.synchronize { @poll_supervisor = supervisor }
925
1009
  supervisor.start
1010
+ # Operator signal (qfg-41nh.6): engage was previously unlogged (only the
1011
+ # stop path logged, at debug). Matches sdk-go's WARN on OnEngage — the
1012
+ # poller engaging means the SDK is in a degraded update mode.
1013
+ LOG.warn "[quonfig] Layer 2 fallback poller engaged (interval=#{poll_ms}ms)"
926
1014
  end
927
1015
 
928
1016
  # Invoke the customer-supplied on_update callback under a rescue. A raise
@@ -18,7 +18,18 @@ module Quonfig
18
18
 
19
19
  CONFIGS_PATH = '/api/v2/configs'
20
20
 
21
- attr_reader :etag, :version, :environment_id
21
+ attr_reader :version, :environment_id
22
+
23
+ # qfg-7h5d.1.9 (canonical ordering). Diagnostic surface read by the failover/
24
+ # ordering chaos probe and by operators:
25
+ # held_generation — Meta.generation of the currently-installed envelope
26
+ # (nil before the first install).
27
+ # install_count — number of envelopes actually installed (rejected-older
28
+ # and same-generation snapshots do NOT bump this).
29
+ # resolved_from — 'primary' / 'secondary' / '' — which config_api_urls leg
30
+ # produced the currently-held config (HTTP installs only;
31
+ # SSE does not change it).
32
+ attr_reader :held_generation, :install_count
22
33
 
23
34
  # +store+: the Quonfig::ConfigStore to populate on successful fetch.
24
35
  # +options+: a Quonfig::Options instance (supplies sdk_key + config_api_urls).
@@ -37,32 +48,94 @@ module Quonfig
37
48
  end
38
49
 
39
50
  @api_config = Concurrent::Map.new
40
- @etag = nil
51
+ # qfg-7h5d.1.14: per-leg ETag is load-bearing for the parallel hedge. The
52
+ # hedge runs the primary and secondary legs concurrently; a SINGLE shared
53
+ # ETag would (a) let a 304 from one leg mask the other and (b) be a data
54
+ # race with two legs writing it. Each leg keeps its own slot keyed by
55
+ # config_api_urls index, guarded by @etag_mutex (snapshot before the
56
+ # request, write-back after — the network wait happens with no lock held).
57
+ @etags = {}
58
+ @etag_mutex = Mutex.new
41
59
  @version = nil
42
60
  @environment_id = nil
43
61
  @logger = logger || LOG
62
+
63
+ # Canonical-ordering state (qfg-7h5d.1.9). @install_mutex makes the
64
+ # guard-check-and-install atomic across every install path (initial fetch,
65
+ # failover/poll fetch, SSE snapshot, SSE update, fallback poller) — these
66
+ # run on different threads and must never interleave a stale install with a
67
+ # fresh one.
68
+ @held_generation = nil
69
+ @install_count = 0
70
+ @resolved_from_index = nil
71
+ @install_mutex = Mutex.new
72
+ end
73
+
74
+ # Backward-compatible reader: the primary leg's last ETag. Pre-hedge this was
75
+ # a single shared @etag; per-leg isolation now means index 0 is the canonical
76
+ # "the ETag" for callers/tests that read one value.
77
+ def etag
78
+ @etag_mutex.synchronize { @etags[0] }
44
79
  end
45
80
 
46
- # Fetch configs from /api/v2/configs with ETag / If-None-Match caching.
47
- # On 200 responses, installs the envelope into the attached ConfigStore
48
- # (if one was provided).
81
+ # 'primary' / 'secondary' / '' for the leg that produced the currently-held
82
+ # config (config_api_urls index 0 = primary, 1 = secondary).
83
+ def resolved_from
84
+ case @resolved_from_index
85
+ when nil then ''
86
+ when 0 then 'primary'
87
+ when 1 then 'secondary'
88
+ else "url#{@resolved_from_index}"
89
+ end
90
+ end
91
+
92
+ # Fetch configs from /api/v2/configs with per-leg ETag / If-None-Match caching.
93
+ #
94
+ # qfg-7h5d.1.14 — PARALLEL-FAILOVER HEDGE. On every init/refresh fetch the
95
+ # PRIMARY leg (config_api_urls[0]) is fired first, on the CALLING thread. If
96
+ # it answers within config_fetch_hedge_delay_ms it WINS and the secondary is
97
+ # NEVER contacted (cold standby — zero extra load on a healthy system). If the
98
+ # primary is SLOW past the hedge delay OR errors fast, the SECONDARY leg
99
+ # (config_api_urls[1]) is ALSO fired IN PARALLEL on a background thread,
100
+ # at-most-once — the primary is NOT cancelled. Whatever arrives is installed
101
+ # through the EXISTING reject-older guard (#install_envelope), so watermark-MAX
102
+ # falls out for free: a higher generation wins, a late OLDER payload never
103
+ # regresses an established client, and a late NEWER payload heals forward.
104
+ #
105
+ # fetch! returns as soon as the FIRST leg installs (readiness latches off it);
106
+ # any still-running leg keeps running on its own thread, bounded by
107
+ # config_fetch_hedge_abort_ms, and heals forward if it lands a newer
108
+ # generation. There is NO coalescing/in-flight gate — overlapping fetches are
109
+ # safe (per-leg ETag isolation + every install serialized through
110
+ # @install_mutex + the reject-older guard + each leg bounded by the abort), and
111
+ # a coalescing gate would make a manual refresh silently no-op (a contract
112
+ # violation).
49
113
  #
50
114
  # Returns one of:
51
- # :updated -- 200 response; store replaced
52
- # :not_modified -- 304 response; store untouched
53
- # :failed -- every configured source failed
115
+ # :updated -- at least one leg installed a 200 envelope
116
+ # :not_modified -- a leg answered 304 (no change) and nothing installed
117
+ # :failed -- every fired leg failed
54
118
  def fetch!
55
- Array(@options.config_api_urls).each do |api_url|
56
- result = fetch_from(api_url)
57
- return result if result != :failed
58
- end
59
- :failed
119
+ urls = Array(@options.config_api_urls)
120
+ return :failed if urls.empty?
121
+
122
+ # Single leg (or no secondary configured): no hedge, just fetch on the
123
+ # calling thread under the SEQUENTIAL per-URL timeout (config_fetch_timeout_ms
124
+ # is unchanged and still governs any non-hedged path). Preserves the
125
+ # synchronous, single-request-per-call shape the legacy/mock callers depend
126
+ # on.
127
+ return fetch_from(urls[0], 0, timeout_ms: config_fetch_timeout_ms) if urls.length < 2
128
+
129
+ fetch_hedged(urls)
60
130
  end
61
131
 
62
132
  # Apply a ConfigEnvelope (from SSE) to the store. Called by the SSE client
63
- # when a new event arrives.
133
+ # when a new event arrives. SSE is a single-leg live stream, so it carries no
134
+ # config_api_urls index and does not change #resolved_from — but it IS
135
+ # guarded by the same reject-older rule (a late SSE snapshot must not regress
136
+ # an established client).
64
137
  def apply_envelope(envelope)
65
- install_envelope(envelope, source: :sse)
138
+ install_envelope(envelope, source: :sse, source_index: nil)
66
139
  end
67
140
 
68
141
  def calc_config
@@ -83,19 +156,155 @@ module Quonfig
83
156
 
84
157
  private
85
158
 
86
- def fetch_from(source)
87
- conn = Quonfig::HttpConnection.new(source, @options.sdk_key)
159
+ # Hedge orchestration (qfg-7h5d.1.14). Fires the primary leg on its own
160
+ # thread; if it is slow past the hedge delay OR errors fast, ALSO fires the
161
+ # secondary in parallel — at-most-once, never after a fast primary win, never
162
+ # cancelling the primary. Both legs push their settled result to a shared
163
+ # queue. fetch! returns as soon as the FIRST leg INSTALLS (so readiness
164
+ # latches off it); the other leg keeps running on its own thread, bounded by
165
+ # the hedge abort inside fetch_from, and heals forward through the
166
+ # reject-older guard. We never join the slow leg — a hung primary must not
167
+ # block a successful secondary install.
168
+ def fetch_hedged(urls)
169
+ hedge_delay_s = hedge_delay_ms / 1000.0
170
+ abort_ms = hedge_abort_ms
171
+
172
+ # Each fired leg pushes exactly one [:done, index, result] message. A
173
+ # SizedQueue large enough for both legs so a finished leg never blocks on
174
+ # push after we've stopped draining.
175
+ results = Queue.new
176
+
177
+ # At-most-once secondary gate. The mutex makes "a fast primary win
178
+ # suppresses the secondary" and "the hedge-delay elapsing fires it"
179
+ # mutually exclusive — exactly one of suppress/fire wins.
180
+ gate = Mutex.new
181
+ secondary_fired = false
182
+
183
+ # Each leg is wall-clock bounded by abort_ms (HttpConnection enforces a
184
+ # whole-request deadline, qfg-41nh.6), so a leg ALWAYS settles — a
185
+ # slow-drip upstream can no longer hold a leg open past the abort and
186
+ # wedge the drain below. The push lives in an ensure so even a
187
+ # non-StandardError escape still settles this leg's drain slot.
188
+ run_leg = lambda do |index|
189
+ Thread.new do
190
+ result = :failed
191
+ begin
192
+ result = fetch_from(urls[index], index, timeout_ms: abort_ms)
193
+ rescue StandardError => e
194
+ @logger.debug "Hedge leg #{index} failed: #{e.class}: #{e.message}"
195
+ ensure
196
+ results.push([:done, index, result])
197
+ end
198
+ end
199
+ end
200
+
201
+ fire_secondary = lambda do
202
+ spawn = gate.synchronize do
203
+ next false if secondary_fired
204
+
205
+ secondary_fired = true
206
+ end
207
+ run_leg.call(1) if spawn
208
+ end
209
+
210
+ suppress_secondary = lambda do
211
+ gate.synchronize { secondary_fired = true }
212
+ end
213
+
214
+ # The primary always runs. A separate hedge-delay timer thread fires the
215
+ # secondary if the primary has not settled by then — without waiting for
216
+ # the primary to finish.
217
+ primary_thread = run_leg.call(0)
218
+
219
+ hedge_timer = Thread.new do
220
+ sleep hedge_delay_s
221
+ # If the primary is still in flight at the hedge delay, hedge in parallel.
222
+ fire_secondary.call if primary_thread.alive?
223
+ end
224
+
225
+ installed = false
226
+ saw_not_modified = false
227
+ drained = 0
228
+
229
+ # Drain leg results until the FIRST install latches readiness, or until
230
+ # every fired leg has reported (so a both-fail / both-304 cycle still
231
+ # terminates). `fired` is read under the gate because the secondary can be
232
+ # spawned concurrently by the timer or the primary's fast-error path.
233
+ loop do
234
+ fired = gate.synchronize { secondary_fired ? 2 : 1 }
235
+ break if drained >= fired && results.empty?
236
+
237
+ _tag, index, result = results.pop
238
+ drained += 1
239
+
240
+ case result
241
+ when :failed
242
+ # A fast primary error must hedge immediately (do not wait for the
243
+ # timer). The gate keeps the secondary at-most-once.
244
+ fire_secondary.call if index.zero?
245
+ when :not_modified
246
+ saw_not_modified = true
247
+ else # :updated -> a real install
248
+ installed = true
249
+ # If the PRIMARY just won inside the hedge window, close the gate so a
250
+ # racing timer can never fire the secondary — the cold-standby promise.
251
+ suppress_secondary.call if index.zero?
252
+ break
253
+ end
254
+ end
255
+
256
+ # Stop the timer if it is still sleeping (already-fired is harmless).
257
+ hedge_timer.kill if hedge_timer.alive?
258
+
259
+ return :updated if installed
260
+ return :not_modified if saw_not_modified
261
+
262
+ :failed
263
+ end
264
+
265
+ def hedge_delay_ms
266
+ if @options.respond_to?(:config_fetch_hedge_delay_ms) && @options.config_fetch_hedge_delay_ms
267
+ @options.config_fetch_hedge_delay_ms
268
+ else
269
+ Quonfig::Options::DEFAULT_CONFIG_FETCH_HEDGE_DELAY_MS
270
+ end
271
+ end
272
+
273
+ def hedge_abort_ms
274
+ if @options.respond_to?(:config_fetch_hedge_abort_ms) && @options.config_fetch_hedge_abort_ms
275
+ @options.config_fetch_hedge_abort_ms
276
+ else
277
+ Quonfig::Options::DEFAULT_CONFIG_FETCH_HEDGE_ABORT_MS
278
+ end
279
+ end
280
+
281
+ def config_fetch_timeout_ms
282
+ @options.respond_to?(:config_fetch_timeout_ms) ? @options.config_fetch_timeout_ms : nil
283
+ end
284
+
285
+ def fetch_from(source, index = nil, timeout_ms: nil)
286
+ # qfg-7h5d.1.9 / .1.14: bound this single per-leg attempt so a hung upstream
287
+ # aborts (Faraday::TimeoutError, caught below as :failed). On the hedged
288
+ # path the caller passes config_fetch_hedge_abort_ms; on the sequential /
289
+ # single-URL path it passes config_fetch_timeout_ms.
290
+ conn = Quonfig::HttpConnection.new(source, @options.sdk_key, timeout_ms: timeout_ms)
88
291
  headers = {}
89
- headers['If-None-Match'] = @etag if @etag
292
+ # Per-leg ETag: snapshot this leg's slot before the request (no lock held
293
+ # during the network wait).
294
+ etag = etag_for(index)
295
+ headers['If-None-Match'] = etag if etag
90
296
  response = conn.get(CONFIGS_PATH, headers)
91
297
 
92
298
  case response.status
93
299
  when 200
94
300
  new_etag = response.headers['ETag'] || response.headers['etag']
95
301
  envelope = parse_envelope(response.body)
96
- install_envelope(envelope, source: source)
97
- @etag = new_etag
98
- :updated
302
+ result = install_envelope(envelope, source: source, source_index: index)
303
+ # Write this leg's ETag back AFTER the response (per-leg, race-free).
304
+ set_etag_for(index, new_etag)
305
+ # install_envelope returns :not_modified when the reject-older guard drops
306
+ # an equal/older payload — surface that so the caller doesn't double-count.
307
+ result == :not_modified ? :not_modified : :updated
99
308
  when 304
100
309
  @logger.debug "Configs not modified (304) from #{source}"
101
310
  :not_modified
@@ -114,6 +323,14 @@ module Quonfig
114
323
  :failed
115
324
  end
116
325
 
326
+ def etag_for(index)
327
+ @etag_mutex.synchronize { @etags[index || 0] }
328
+ end
329
+
330
+ def set_etag_for(index, value)
331
+ @etag_mutex.synchronize { @etags[index || 0] = value }
332
+ end
333
+
117
334
  def parse_envelope(body)
118
335
  data = body.is_a?(String) ? JSON.parse(body) : body
119
336
  Quonfig::ConfigEnvelope.new(
@@ -129,37 +346,74 @@ module Quonfig
129
346
  str.length > 200 ? "#{str[0, 200]}..." : str
130
347
  end
131
348
 
132
- def install_envelope(envelope, source:)
133
- # Update internal tracking map (for legacy callers / introspection).
134
- next_map = Concurrent::Map.new
135
- envelope.configs.each do |cfg|
136
- key = config_key(cfg)
137
- next if key.nil?
349
+ def install_envelope(envelope, source:, source_index: nil)
350
+ meta = envelope.meta || {}
351
+ incoming_gen = extract_generation(meta)
352
+
353
+ @install_mutex.synchronize do
354
+ # Reject-older install guard (canonical ordering, §5f). A fresh client
355
+ # (no held generation) seeds off whatever arrives first — even an older
356
+ # or gen-0 snapshot. An established client installs ONLY when the incoming
357
+ # generation strictly advances the held one: a same-generation snapshot is
358
+ # a no-op (no store churn, no install-count bump, no resolved-from change)
359
+ # so a duplicate leg never flaps an established client, and an OLDER
360
+ # snapshot (a stale secondary reached on failover) is dropped so the client
361
+ # never regresses. Reject-older is the whole rule — no source ranking; a
362
+ # newer primary landing late heals forward automatically. Applies on every
363
+ # network install path (initial fetch, failover/poll fetch, SSE snapshot,
364
+ # SSE update, fallback poller); datadir install bypasses this (it is the
365
+ # local source of truth and goes through Client#apply_datadir_envelope).
366
+ # Carve-out: an UNVERSIONED snapshot (generation <= 0 — a server that
367
+ # predates the watermark, or one whose rev-count failed) carries no
368
+ # ordering info, so it is never rejected as "older"; freezing the client
369
+ # on stale config would be worse (mirrors sdk-node).
370
+ unless @held_generation.nil? || incoming_gen <= 0 || incoming_gen > @held_generation
371
+ @logger.debug "Reject-older guard: dropping incoming generation #{incoming_gen} <= held #{@held_generation} (source=#{source})"
372
+ return :not_modified
373
+ end
138
374
 
139
- next_map[key] = { source: source, config: cfg }
140
- end
141
- @api_config = next_map
375
+ # Update internal tracking map (for legacy callers / introspection).
376
+ next_map = Concurrent::Map.new
377
+ envelope.configs.each do |cfg|
378
+ key = config_key(cfg)
379
+ next if key.nil?
142
380
 
143
- meta = envelope.meta || {}
144
- @version = meta['version'] || meta[:version] || @version
145
- @environment_id = meta['environment'] || meta[:environment] || @environment_id
381
+ next_map[key] = { source: source, config: cfg }
382
+ end
383
+ @api_config = next_map
384
+
385
+ @version = meta['version'] || meta[:version] || @version
386
+ @environment_id = meta['environment'] || meta[:environment] || @environment_id
146
387
 
147
- # Replace the live store atomically.
148
- return if @store.nil?
388
+ @held_generation = incoming_gen
389
+ @install_count += 1
390
+ @resolved_from_index = source_index unless source_index.nil?
149
391
 
150
- new_keys = next_map.keys.to_set
151
- old_keys = @store.keys.to_set
152
- # Drop keys that disappeared server-side.
153
- (old_keys - new_keys).each { |k| @store.delete(k) } if @store.respond_to?(:delete)
392
+ # Replace the live store atomically.
393
+ return if @store.nil?
154
394
 
155
- envelope.configs.each do |cfg|
156
- key = config_key(cfg)
157
- next if key.nil?
395
+ new_keys = next_map.keys.to_set
396
+ old_keys = @store.keys.to_set
397
+ # Drop keys that disappeared server-side.
398
+ (old_keys - new_keys).each { |k| @store.delete(k) } if @store.respond_to?(:delete)
158
399
 
159
- @store.set(key, cfg)
400
+ envelope.configs.each do |cfg|
401
+ key = config_key(cfg)
402
+ next if key.nil?
403
+
404
+ @store.set(key, cfg)
405
+ end
160
406
  end
161
407
  end
162
408
 
409
+ # Read Meta.generation (qfg-7h5d.1.1) — the monotonic per-branch commit
410
+ # counter the backend stamps on every envelope. Absent/garbage → 0 (an old
411
+ # backend that doesn't emit it, or fixture mode with no FIXTURE_GENERATION).
412
+ def extract_generation(meta)
413
+ g = meta['generation'] || meta[:generation]
414
+ g.is_a?(Numeric) ? g.to_i : 0
415
+ end
416
+
163
417
  def config_key(cfg)
164
418
  return cfg['key'] || cfg[:key] if cfg.is_a?(Hash)
165
419
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'base64'
4
4
  require 'json'
5
+ require 'timeout'
5
6
 
6
7
  module Quonfig
7
8
  class HttpConnection
@@ -13,30 +14,77 @@ module Quonfig
13
14
  'X-Quonfig-SDK-Version' => SDK_VERSION
14
15
  }.freeze
15
16
 
16
- def initialize(uri, sdk_key)
17
+ # qfg-41nh.6 (WS2.4): headroom added to +timeout_ms+ to form the WALL-CLOCK
18
+ # ceiling for the whole request. Faraday's open/read timeouts are per-phase
19
+ # and — on the Net::HTTP adapter — per-READ: every byte that arrives resets
20
+ # the read deadline, so a slow-drip upstream (one byte per interval) holds a
21
+ # "bounded" request open indefinitely and wedges the caller (init fetch,
22
+ # fallback poll tick, hedge drain). The wall clock is the true per-leg abort
23
+ # (sdk-go's per-leg context deadline is wall-clock). The headroom keeps
24
+ # Faraday's own, more specific phase timeouts winning the simple-stall race;
25
+ # the wall clock only fires on drip-feed pathologies that per-read deadlines
26
+ # structurally cannot catch.
27
+ WALL_CLOCK_HEADROOM_S = 0.25
28
+
29
+ # +timeout_ms+ (qfg-7h5d.1.9): per-request bound applied to BOTH the connect
30
+ # (open) and read phases of every request made through this connection, AND
31
+ # (qfg-41nh.6) enforced as a wall-clock ceiling over the whole request. nil
32
+ # leaves Faraday's defaults (no timeout) in place — preserving the prior
33
+ # behavior for callers that don't pass one. The config-fetch path passes
34
+ # Options#config_fetch_timeout_ms (sequential) or the hedge abort (hedged
35
+ # legs) so a hung OR drip-feeding upstream aborts fast instead of blocking
36
+ # the caller's whole init budget.
37
+ def initialize(uri, sdk_key, timeout_ms: nil)
17
38
  @uri = uri
18
39
  @sdk_key = sdk_key
40
+ @timeout_ms = timeout_ms
19
41
  end
20
42
 
21
43
  attr_reader :uri
22
44
 
23
45
  def get(path, headers = {})
24
- connection(headers).get(path)
46
+ with_wall_clock_deadline { connection(headers).get(path) }
25
47
  end
26
48
 
27
49
  def post(path, body)
28
- connection.post(path, body.to_json)
50
+ with_wall_clock_deadline { connection.post(path, body.to_json) }
29
51
  end
30
52
 
31
53
  def connection(headers = {})
32
54
  merged = JSON_HEADERS.merge('Authorization' => auth_header).merge(headers)
33
55
  Faraday.new(@uri) do |conn|
34
56
  conn.headers.merge!(merged)
57
+ if @timeout_ms
58
+ seconds = @timeout_ms / 1000.0
59
+ # open_timeout bounds the TCP connect; timeout bounds the read. A
60
+ # 'timeout' toxic accepts the connection but never sends bytes, so the
61
+ # read deadline is the one that fires — set both so a refused/slow
62
+ # connect is bounded too.
63
+ conn.options.open_timeout = seconds
64
+ conn.options.timeout = seconds
65
+ end
35
66
  end
36
67
  end
37
68
 
38
69
  private
39
70
 
71
+ # Enforce +timeout_ms+ (+ headroom) as a wall-clock deadline over the whole
72
+ # request. Timeout.timeout is the codebase's accepted backstop pattern (the
73
+ # init path wraps fetch! the same way in Client#perform_initial_fetch); a
74
+ # deadline check inside the read loop isn't practical here because Faraday
75
+ # 1.x's Net::HTTP adapter exposes no per-chunk streaming hook. Raises
76
+ # Faraday::TimeoutError so callers' existing timeout rescue paths apply
77
+ # unchanged. No-op when no timeout_ms was configured.
78
+ def with_wall_clock_deadline(&block)
79
+ return block.call unless @timeout_ms
80
+
81
+ deadline_s = (@timeout_ms / 1000.0) + WALL_CLOCK_HEADROOM_S
82
+ Timeout.timeout(deadline_s, Faraday::TimeoutError,
83
+ "wall-clock per-request deadline #{deadline_s}s exceeded for #{@uri}") do
84
+ block.call
85
+ end
86
+ end
87
+
40
88
  def auth_header
41
89
  "Basic #{Base64.strict_encode64("1:#{@sdk_key}")}"
42
90
  end
@@ -7,7 +7,8 @@ module Quonfig
7
7
  class Options
8
8
  attr_reader :sdk_key, :environment, :api_urls, :sse_api_urls, :telemetry_destination, :config_api_urls,
9
9
  :on_no_default, :init_timeout_ms, :on_init_failure, :collect_sync_interval, :datadir, :enable_sse, :fallback_poll_enabled, :fallback_poll_interval_ms, :global_context, :logger_key, :logger, :enable_quonfig_user_context,
10
- :data_dir_auto_reload, :data_dir_auto_reload_debounce_ms
10
+ :data_dir_auto_reload, :data_dir_auto_reload_debounce_ms, :config_fetch_timeout_ms,
11
+ :config_fetch_hedge_delay_ms, :config_fetch_hedge_abort_ms
11
12
  attr_accessor :is_fork
12
13
 
13
14
  # Default fallback poll interval, in milliseconds. The SDK polls api-delivery
@@ -18,6 +19,38 @@ module Quonfig
18
19
  # long for the initial config fetch before failing per :on_init_failure.
19
20
  DEFAULT_INIT_TIMEOUT_MS = 10_000
20
21
 
22
+ # Default per-URL config-fetch timeout, in milliseconds (qfg-7h5d.1.9). Each
23
+ # leg in config_api_urls gets its own bounded attempt on the initial fetch
24
+ # AND the fallback poller, so a hung primary aborts fast (~3s) and leaves
25
+ # budget to reach the secondary inside init_timeout_ms instead of starving it
26
+ # until the global deadline. ~3s is short enough to fail over well inside a
27
+ # default 10s init budget, long enough to tolerate a slow-but-healthy
28
+ # upstream. Additive + a default that already fails over → backward
29
+ # compatible, not a breaking change.
30
+ DEFAULT_CONFIG_FETCH_TIMEOUT_MS = 3_000
31
+
32
+ # Default hedge delay, in milliseconds (qfg-7h5d.1.14). On the init/refresh
33
+ # config-fetch the SDK fires the PRIMARY leg first; if it has not settled
34
+ # within this delay (or errors fast) the SDK ALSO fires the secondary leg in
35
+ # PARALLEL without cancelling the primary. ~2s is below a realistic
36
+ # slow-but-alive primary's worst case yet far enough below the per-leg abort
37
+ # that a healthy sub-second primary is NEVER hedged — the secondary stays a
38
+ # cold standby and a healthy system adds zero secondary load. Standardized to
39
+ # 2000ms across all backend SDKs (qfg-7h5d.1.14). Tunable via
40
+ # +config_fetch_hedge_delay_ms+. Additive + backward compatible.
41
+ DEFAULT_CONFIG_FETCH_HEDGE_DELAY_MS = 2_000
42
+
43
+ # Default per-leg hedge hard-abort deadline, in milliseconds (qfg-7h5d.1.14).
44
+ # The hedged config-fetch path bounds each leg by this instead of
45
+ # #config_fetch_timeout_ms (which still governs the sequential FetchConfigs
46
+ # path). It MUST exceed the longest healable primary latency so a late-but-
47
+ # newer primary heals forward (rather than aborting), and MUST be <
48
+ # init_timeout_ms so the init-path heal leg is not clipped — the client logs a
49
+ # warning at construction if init_timeout_ms <= this value. ~6s sits between a
50
+ # ~3s slow-but-healthy upstream and the default 10s init budget. Tunable via
51
+ # +config_fetch_hedge_abort_ms+. Additive + backward compatible.
52
+ DEFAULT_CONFIG_FETCH_HEDGE_ABORT_MS = 6_000
53
+
21
54
  # Deprecated alias for #fallback_poll_enabled. Will be removed in a future
22
55
  # minor release.
23
56
  def enable_polling
@@ -184,6 +217,9 @@ module Quonfig
184
217
  init_timeout_ms: nil,
185
218
  initialization_timeout_sec: nil,
186
219
  on_init_failure: ON_INITIALIZATION_FAILURE::RAISE,
220
+ config_fetch_timeout_ms: nil,
221
+ config_fetch_hedge_delay_ms: nil,
222
+ config_fetch_hedge_abort_ms: nil,
187
223
  collect_max_paths: DEFAULT_MAX_PATHS,
188
224
  collect_sync_interval: nil,
189
225
  context_upload_mode: :periodic_example, # :periodic_example, :shapes_only, :none
@@ -238,6 +274,14 @@ module Quonfig
238
274
  DEFAULT_INIT_TIMEOUT_MS
239
275
  end
240
276
  @on_init_failure = on_init_failure
277
+ # qfg-7h5d.1.9: per-URL config-fetch timeout. nil → DEFAULT_CONFIG_FETCH_TIMEOUT_MS.
278
+ @config_fetch_timeout_ms = config_fetch_timeout_ms || DEFAULT_CONFIG_FETCH_TIMEOUT_MS
279
+ # qfg-7h5d.1.14: parallel-failover hedge knobs. nil → defaults. The hedge
280
+ # delay is when the secondary ALSO fires in parallel; the hedge abort is the
281
+ # per-leg hard deadline on the hedged path (the sequential FetchConfigs path
282
+ # keeps using config_fetch_timeout_ms).
283
+ @config_fetch_hedge_delay_ms = config_fetch_hedge_delay_ms || DEFAULT_CONFIG_FETCH_HEDGE_DELAY_MS
284
+ @config_fetch_hedge_abort_ms = config_fetch_hedge_abort_ms || DEFAULT_CONFIG_FETCH_HEDGE_ABORT_MS
241
285
 
242
286
  @collect_max_paths = collect_max_paths
243
287
  @collect_sync_interval = collect_sync_interval
@@ -87,8 +87,23 @@ module Quonfig
87
87
  @conn_mutex = Mutex.new
88
88
  @active_http = nil
89
89
 
90
- @source_index = -1
91
90
  @last_event_id = nil
91
+ # qfg-7h5d.1.9 / qfg-41nh.6: latches true if a connection attempt ever
92
+ # targets a non-primary sse_api_urls leg. The failover epic asserts SSE
93
+ # does NOT fail over to the secondary (f05) — the stream is PINNED to
94
+ # sse_api_urls[0] and degrades via the single-upstream SSE↔HTTP fallback,
95
+ # so with a correct #current_url this never flips even when a secondary
96
+ # stream URL is configured (options derive one from api_urls by default).
97
+ # The latch is measured at the dial site in #stream_once, so a regression
98
+ # that reintroduces stream-URL rotation is observable to the failover
99
+ # chaos probe (f05) and to operators.
100
+ @failed_over_to_secondary = false
101
+ end
102
+
103
+ # True if the live SSE stream has ever connected to a non-primary leg. Read
104
+ # by the failover chaos probe (f05). See @failed_over_to_secondary.
105
+ def failed_over_to_secondary?
106
+ @failed_over_to_secondary
92
107
  end
93
108
 
94
109
  # Layer 1 (SSE) reconnect counter. Bumped exactly once per reconnect
@@ -256,7 +271,13 @@ module Quonfig
256
271
  # gotcha and solve it the same way: per-chunk reset, async close on
257
272
  # expiry (chaos scenario 02 — sse_silent_stall).
258
273
  def stream_once(&block)
259
- url = "#{current_url}/api/v2/sse/config"
274
+ base = current_url
275
+ # f05 latch: record if this connection attempt targets a non-primary
276
+ # stream leg. With the pin in #current_url this is structurally false;
277
+ # measuring at the dial site (rather than inside URL selection) keeps a
278
+ # rotation regression observable to the chaos probe.
279
+ @failed_over_to_secondary = true if base != @prefab_options.sse_api_urls.first
280
+ url = "#{base}/api/v2/sse/config"
260
281
  cursor = current_cursor
261
282
  @logger.debug "SSE Streaming Connect to #{url} start_at #{cursor.inspect}"
262
283
 
@@ -392,13 +413,15 @@ module Quonfig
392
413
  end
393
414
  end
394
415
 
395
- # Rotate through configured SSE URLs. The same rotation rule the
396
- # previous implementation used, preserved so multi-region failover
397
- # behavior is unchanged.
416
+ # The stream leg is PINNED to the primary stream URL (sse_api_urls[0])
417
+ # and retried forever with backoff SSE never fails over (f05 invariant,
418
+ # qfg-41nh.6). Failover is HTTP-poll only: the config-fetch hedge keeps
419
+ # BOTH api_urls legs, but the live stream never repoints to
420
+ # stream.secondary (which is sized for poll rps, not SSE herds, and would
421
+ # silently bind freshness to the mirror). Matches sdk-go's sseClient — a
422
+ # single pinned URL with reconnect backoff (sse_client.go runLoop).
398
423
  def current_url
399
- urls = @prefab_options.sse_api_urls
400
- @source_index = (@source_index + 1) % urls.size
401
- urls[@source_index]
424
+ @prefab_options.sse_api_urls.first
402
425
  end
403
426
 
404
427
  # Internal: HTTP-status sentinel error for non-200 SSE responses. Surfaces
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Quonfig
4
- VERSION = '1.0.0'
4
+ VERSION = '1.1.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.0.0
4
+ version: 1.1.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-06-06 00:00:00.000000000 Z
11
+ date: 2026-07-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport