quonfig 1.1.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: d12e38af54000d7cfeb34f7c3f50739527e63448668abf9d6fd4b436b27b277f
4
- data.tar.gz: 1421bc6f53246ee1260a5c832ef342faa5ca70b6854acba6f12d9b51a464e99d
3
+ metadata.gz: df149576c34f45d691f7252a2f0e05b2c02b95681ab9986fac6a373071210c7e
4
+ data.tar.gz: ab5dc409a48ed4f471b500d62451a8d1b3b1e7de67871172ce684327ba7a1777
5
5
  SHA512:
6
- metadata.gz: 73d25bb37c6c30e1d1b756172e0ffd4885bf901c9b600eec3c060f06bead7b45cf74e09544908d925a4345fcb5f9b6efe6d8331d7f8544079ffabf7b9d5b1228
7
- data.tar.gz: 04df7b6f24efb0f9a3c30c47e32c686f2407ac54c1776eb7a2313fe1332f8339eac4693fc49edb49d76e00b7521680701bedfbfae5839b4d9158b3ace473efbc
6
+ metadata.gz: dec2b311edf9f515e00803d7f2a60c9f10897807779b26a44f4aa842ef5ac46239a432a194e72378eb23c3e03a79002ff3332e6d72be265683b707adb88134ed
7
+ data.tar.gz: 55c3a892dfa3af12e4db273e09faf5f0b96c8c2861cdfdb611c9ba75877a6e3a1c696568593b0cdd6283874b072f259738cc14c8d3a871a432cfbc17a194df35
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
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
+
3
10
  ## 1.1.0 - 2026-07-01
4
11
 
5
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`).
@@ -970,18 +970,35 @@ module Quonfig
970
970
 
971
971
  poll_seconds = poll_ms / 1000.0
972
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).
973
978
  worker = lambda do |notify_delivered|
974
979
  loop do
975
980
  break if stopped_ref.call
976
981
 
977
- 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
+
978
999
  break if stopped_ref.call
979
1000
 
980
- @config_loader.fetch!
981
- sync_evaluator_env_id!
982
- record_refresh!
983
- notify_delivered.call
984
- notify_on_update_callback
1001
+ sleep poll_seconds
985
1002
  end
986
1003
  end
987
1004
 
@@ -990,6 +1007,10 @@ module Quonfig
990
1007
  )
991
1008
  @state_mutex.synchronize { @poll_supervisor = supervisor }
992
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)"
993
1014
  end
994
1015
 
995
1016
  # Invoke the customer-supplied on_update callback under a rescue. A raise
@@ -180,15 +180,21 @@ module Quonfig
180
180
  gate = Mutex.new
181
181
  secondary_fired = false
182
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.
183
188
  run_leg = lambda do |index|
184
189
  Thread.new do
185
- result = begin
186
- fetch_from(urls[index], index, timeout_ms: abort_ms)
190
+ result = :failed
191
+ begin
192
+ result = fetch_from(urls[index], index, timeout_ms: abort_ms)
187
193
  rescue StandardError => e
188
194
  @logger.debug "Hedge leg #{index} failed: #{e.class}: #{e.message}"
189
- :failed
195
+ ensure
196
+ results.push([:done, index, result])
190
197
  end
191
- results.push([:done, index, result])
192
198
  end
193
199
  end
194
200
 
@@ -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,13 +14,26 @@ module Quonfig
13
14
  'X-Quonfig-SDK-Version' => SDK_VERSION
14
15
  }.freeze
15
16
 
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
+
16
29
  # +timeout_ms+ (qfg-7h5d.1.9): per-request bound applied to BOTH the connect
17
- # (open) and read phases of every request made through this connection. nil
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
18
32
  # leaves Faraday's defaults (no timeout) in place — preserving the prior
19
33
  # behavior for callers that don't pass one. The config-fetch path passes
20
- # Options#config_fetch_timeout_ms so a hung upstream (accepts the TCP
21
- # connection but never responds) aborts fast instead of blocking the caller's
22
- # whole init budget.
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.
23
37
  def initialize(uri, sdk_key, timeout_ms: nil)
24
38
  @uri = uri
25
39
  @sdk_key = sdk_key
@@ -29,11 +43,11 @@ module Quonfig
29
43
  attr_reader :uri
30
44
 
31
45
  def get(path, headers = {})
32
- connection(headers).get(path)
46
+ with_wall_clock_deadline { connection(headers).get(path) }
33
47
  end
34
48
 
35
49
  def post(path, body)
36
- connection.post(path, body.to_json)
50
+ with_wall_clock_deadline { connection.post(path, body.to_json) }
37
51
  end
38
52
 
39
53
  def connection(headers = {})
@@ -54,6 +68,23 @@ module Quonfig
54
68
 
55
69
  private
56
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
+
57
88
  def auth_header
58
89
  "Basic #{Base64.strict_encode64("1:#{@sdk_key}")}"
59
90
  end
@@ -87,14 +87,16 @@ 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
92
- # qfg-7h5d.1.9: latches true if the SSE reconnect rotation ever selects a
93
- # non-primary (index > 0) sse_api_urls leg. The failover epic asserts SSE
94
- # does NOT fail over to the secondary (f05) — it stays on its own endpoint
95
- # and degrades via the single-upstream SSE↔HTTP fallback. With a single SSE
96
- # URL configured this can never flip; the flag makes the design choice
97
- # observable (and would surface a regression if SSE were given two legs).
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.
98
100
  @failed_over_to_secondary = false
99
101
  end
100
102
 
@@ -269,7 +271,13 @@ module Quonfig
269
271
  # gotcha and solve it the same way: per-chunk reset, async close on
270
272
  # expiry (chaos scenario 02 — sse_silent_stall).
271
273
  def stream_once(&block)
272
- 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"
273
281
  cursor = current_cursor
274
282
  @logger.debug "SSE Streaming Connect to #{url} start_at #{cursor.inspect}"
275
283
 
@@ -405,14 +413,15 @@ module Quonfig
405
413
  end
406
414
  end
407
415
 
408
- # Rotate through configured SSE URLs. The same rotation rule the
409
- # previous implementation used, preserved so multi-region failover
410
- # 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).
411
423
  def current_url
412
- urls = @prefab_options.sse_api_urls
413
- @source_index = (@source_index + 1) % urls.size
414
- @failed_over_to_secondary = true if @source_index.positive?
415
- urls[@source_index]
424
+ @prefab_options.sse_api_urls.first
416
425
  end
417
426
 
418
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.1.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.1.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-07-01 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