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 +4 -4
- data/CHANGELOG.md +7 -0
- data/lib/quonfig/client.rb +27 -6
- data/lib/quonfig/config_loader.rb +10 -4
- data/lib/quonfig/http_connection.rb +37 -6
- data/lib/quonfig/sse_config_client.rb +24 -15
- data/lib/quonfig/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: df149576c34f45d691f7252a2f0e05b2c02b95681ab9986fac6a373071210c7e
|
|
4
|
+
data.tar.gz: ab5dc409a48ed4f471b500d62451a8d1b3b1e7de67871172ce684327ba7a1777
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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`).
|
data/lib/quonfig/client.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 =
|
|
186
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
21
|
-
#
|
|
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
|
|
93
|
-
# non-primary
|
|
94
|
-
# does NOT fail over to the secondary (f05) —
|
|
95
|
-
# and degrades via the single-upstream SSE↔HTTP fallback
|
|
96
|
-
#
|
|
97
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
409
|
-
#
|
|
410
|
-
#
|
|
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
|
-
|
|
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
|
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.1.
|
|
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-
|
|
11
|
+
date: 2026-07-06 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|