quonfig 1.8.0 → 1.9.0

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: c41b5b972edb95e3718fc1b46f693cf7a1800900e9228c9fd71542b6a775a001
4
- data.tar.gz: c0d0f5f0c25d44b3d580285d0459a73e829827744c89cb5d8d4ab584fb13bc9a
3
+ metadata.gz: 06b78bb9ba3c5950d54b844388ca57e91c23c7bf4cb595c82c57738e807a1716
4
+ data.tar.gz: be216cc0d6ea8cee975bc799b94ac2f5a97829dae7fcb4056fded44835deb918
5
5
  SHA512:
6
- metadata.gz: b215ffbd82dc9bc5aef5211a11e8072ab5556637106dc85c65de50652c81849578b4b738d25d0fd4f0113d12ec39ed7aedd0f71bcf461646499e9b50b6001ede
7
- data.tar.gz: 16836a6666e7e6648baa99f73ad4bf9c491638ad9e31d67a66821040f004ba4382ce6d7532fe40674f40c88ab0ccb467f94641b329e4ac5387590647df5be97f
6
+ metadata.gz: 3abbf9a8a7afe7883670d60869289fab0e1263d56a309713c89dc3d187fe9e8460905ae6d89f98bf3c1bfc17c45d81238abdc3cb693bf892aace508fff06ea59
7
+ data.tar.gz: fe1b42b9a3b97d41eabad20fd06610d3a7b237226e32b7af76463f98d5ee7425c0efe6958babbe4d9d0ccaf633d5d8b7c0d587a3245f2a9517534133b4691021
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.0 - 2026-10-20
4
+
5
+ - **UPGRADE: SSE reports `connected` at the first frame, not at the 200 response (qfg-goi1.10.9).** A stream connection now counts as connected, for `connection_state`, the reconnect backoff and the fallback poller, only after it delivers its first valid frame: a config envelope or a keepalive. Before, it counted as soon as the 200 response headers arrived.
6
+ - Against Quonfig's servers the headers and the first frame arrive together, so you should see no difference.
7
+ - `connection_state` now reports `:disconnected` (then `:falling_back`) when the stream ends before any frame, where it used to keep reporting `:connected` on stale config; a keepalive now counts as a live stream, so a quiet workspace no longer starts fallback polling while its stream is healthy.
8
+ - A connection that closes before any frame is now a failed attempt. The reconnect delay grows, the client reports disconnected, and the fallback poller takes over after its usual threshold.
9
+
10
+ Before this change, a server that answered 200 and closed at once left the SDK on stale config indefinitely while `connection_state` reported `:connected`.
11
+ - **UPGRADE: may change returned values. Minor: one config that fails to load no longer raises out of every read of its key (qfg-krjt).** A config whose rule, criterion or default has the wrong shape (for example `rules: ["oops"]`, `criteria: "oops"` or `default: "oops"`), or whose weighted value has a `weight` that is not an integer (a string, float or null), used to install and then raise `NoMethodError` or `TypeError` out of `get`, `enabled?` and the typed getters on every read of that key. That entry is now skipped when the payload is decoded (HTTP, SSE or a datadir file) with a WARN naming its key, the rest installs, and the skipped key reads as not found (your default, `false` from `enabled?`, or `FLAG_NOT_FOUND` in the `*_details` getters). A payload whose every entry fails is still rejected (a failed fetch leg on HTTP, a dropped event on SSE, a load error in datadir mode), so it cannot install as an empty workspace. A weighted value with no `weight` now counts as 0 instead of raising. Matches sdk-go, sdk-node and sdk-python. Test: `test/test_malformed_config_skip.rb`.
12
+ - **Changed (minor): the first-frame rule in the SSE worker (qfg-goi1.10.43).** A keepalive (a comment-only block such as `: keepalive`) now resets the reconnect backoff to its initial delay and cancels the fallback poller's engage timer (or stops an engaged poller), like an envelope; it does not advance `last_successful_refresh`. A 200 stream that ends before its first frame now calls the internal `on_error` path (logged at WARN), so the poller engages at once if SSE never connected, or after 2x the poll interval following a mid-life drop. `connection_state` still stays `:connected` across a clean reconnect after a session that delivered a frame, but only until that next connection either delivers its own first frame or ends without one. Contract: integration-test-data `v2026.10.16.4`, supervisor contract Test 9 (a)-(d) and the Test 2 reset amendment, in `test/test_sse_first_frame.rb`.
13
+ - **Tests: unit and release tests now run integration-test-data `v2026.10.16.4`.** It also moves the two `get_or_raise` init-timeout cases to `http://192.0.2.1:1` (qfg-goi1.6). The chaos workflows stay on `v2026.10.16.2`.
14
+ - **Fix (patch): `Client#stop` no longer leaks the SSE worker thread or waits 2 seconds (qfg-yrly).** Stopping a client with a live stream left the SSE worker blocked forever: the worker left the stream with a `break` inside `read_body`, and Net::HTTP then read the rest of the endless stream to finish the response. `stop` waited 2s for the worker and then returned with the thread still running, and the thread kept the old client alive in memory. The SDK also closes the stream when it enters auth-failed, so each auth-failed entry leaked one thread too. The worker now leaves the stream without that read, so `stop` returns promptly and the thread exits. Tests: `test/test_sse_close_during_stream.rb` (close during a live stream and during a blocked read), and the close assertion in `test/test_sse_config_client.rb` now checks the real worker thread.
15
+ - **Fix (patch): a config update is now applied to the store all at once (qfg-i86q).** The SDK wrote each config of a new envelope into the live store one key at a time, so a request evaluating during that write could see a flag from the new envelope with a segment from the old one, or a key that was briefly missing, and get the wrong value with no error or log. Each install (SSE, polling, the initial fetch and a datadir reload) now builds the new set of configs first and swaps it in with one write, like sdk-go, and each lookup reads one snapshot from start to finish (the flag, its segments and any `decryptWith` key). `ConfigStore` gains `replace_all`; `get`, `set`, `delete`, `keys` and `all_configs` behave as before. Test: `test/test_config_loader_atomic_install.rb` (a reader during 198 installs; before this fix about 40% of its reads were wrong).
16
+
3
17
  ## 1.8.0 - 2026-10-16
4
18
 
5
19
  - **UPGRADE: retry cadence and log levels change after a rejected 401/403/404/410 fetch cycle; a revoked key logs one config ERROR. `terminal_failure?` changes meaning. Minor (1.8.0): one auth-failure rule across the SDKs (qfg-goi1.10.26). This reverses the qfg-i5xv latch.**
@@ -14,6 +28,7 @@
14
28
  - **Whole-number floats compare as plain digits.** In `PROP_IS_ONE_OF` / `PROP_IS_NOT_ONE_OF`, the starts-with / ends-with / contains operators and `HIERARCHICAL_MATCH`, a context value of `1.0`, `1234567.0` or `5e9` now reads as `"1"`, `"1234567"`, `"5000000000"` (it was `"1.0"`, `"1234567.0"`, `"5.0e9"`), so `is one of ["1234567"]` matches it and a list holding `"1.0"` no longer does. Non-integral floats (`1.5`) and Integers are unchanged; booleans still read `"true"` / `"false"`.
15
29
  - **Abbreviated versions.** `PROP_SEMVER_*` reads `"2"` as `2.0.0` and `"2.0"` / `"4.1"` as `2.0.0` / `4.1.0`, on the context and the rule side, so an app reporting `"4.1"` now matches a `4.1.0` rule. A prerelease or build suffix still needs the full `x.y.z`, and a `v` prefix is still not a version. `SemanticVersion.parse` itself stays strict.
16
30
  Matches integration-test-data `v2026.10.16.1` and the other SDKs.
31
+ Approved as minors under Decision 1 (qfg-goi1.5, 2026-10-09).
17
32
  - **UPGRADE: may change returned values. Minor: JSON values are returned as a fresh copy on every call (qfg-goi1.10.3).** `get`, `get_json` and `get_json_details` (also on `BoundClient`) used to return the SDK's own Hash or Array for a JSON config. If your code changed the result (for example added a key or appended to a list), every later read in the process saw the change. Each call now returns its own copy of the Hashes and Arrays, so changes stay local. What changes for you: two calls no longer return the same object (`equal?` is false; `==` is still true), and code that relied on mutating the result to change config no longer does. Values are not frozen, so code that mutates its own copy keeps working. Strings are not copied: `get_string` and the strings inside a JSON value are the same objects as before. Matches sdk-go, sdk-net and sdk-java.
18
33
  - **CI: unit and release tests now run integration-test-data `v2026.10.16.1` (qfg-goi1.5.7).** It adds the OPERATOR EDGES cases (bool and digit-string one-of, int ranges, prerelease and abbreviated semver, strict dates). The chaos workflows stay on `v2026.10.03`.
19
34
  - **Fix (patch): `EnvVarParseError` messages now use the cross-SDK wording (qfg-goi1.10.21).** An ENV_VAR value that cannot be converted to the config's type now raises with `environment variable "<NAME>" cannot be converted to <type> for config "<key>"` (for example `... cannot be converted to int for config "my.key"`), the template pinned by integration-test-data `v2026.10.12`. It was `Evaluating <key> couldn't coerce <NAME> to <type>`. A malformed stored duration reads `stored value cannot be converted to duration for config "<key>"`. The value is still never in the message. Only the message text changes; the exception class and when it is raised are unchanged.
@@ -1090,6 +1090,10 @@ module Quonfig
1090
1090
  @sse_error_callback ||= ->(error) { handle_sse_error(error) }
1091
1091
  end
1092
1092
 
1093
+ def sse_connected_callback
1094
+ @sse_connected_callback ||= -> { handle_sse_state_change(:connected) unless @stopped }
1095
+ end
1096
+
1093
1097
  # Every SSE error, auth-class included, is an ordinary drop: SSE alone
1094
1098
  # never enters auth-failed (rule R2, qfg-goi1.10.26; this reverses the
1095
1099
  # qfg-i5xv latch). The fallback poller engages on its usual schedule and
@@ -1273,10 +1277,8 @@ module Quonfig
1273
1277
  # propagates through the auto-reload path. Records a refresh timestamp.
1274
1278
  # Caller is responsible for firing on_update.
1275
1279
  def apply_datadir_envelope(envelope)
1276
- new_keys = envelope.configs.map { |cfg| cfg['key'] }.compact.to_set
1277
- old_keys = @store.keys.to_set
1278
- (old_keys - new_keys).each { |k| @store.delete(k) }
1279
- envelope.configs.each { |cfg| @store.set(cfg['key'], cfg) }
1280
+ # One atomic swap, like a network install (qfg-i86q).
1281
+ @store.replace_all(envelope.configs.to_h { |cfg| [cfg['key'], cfg] })
1280
1282
  # qfg-pinh: evaluate against the installed envelope's meta.environment,
1281
1283
  # matching sdk-go. In datadir mode the loader stamps meta.environment =
1282
1284
  # the resolved env (the `environment:` pin or QUONFIG_ENVIRONMENT), so
@@ -1494,7 +1496,12 @@ module Quonfig
1494
1496
  @config_loader,
1495
1497
  nil,
1496
1498
  nil,
1497
- on_error: sse_error_callback
1499
+ on_error: sse_error_callback,
1500
+ # First-frame rule (qfg-goi1.10.43): connected at the connection's
1501
+ # first valid frame, keepalive included, never on the 200 headers.
1502
+ # Cancels the engage timer / disengages the poller without stamping
1503
+ # last_successful_refresh (only an install or fetch does that).
1504
+ on_connected: sse_connected_callback
1498
1505
  )
1499
1506
  @sse_client.start do |envelope, _event, _source|
1500
1507
  next if @stopped
@@ -448,6 +448,11 @@ module Quonfig
448
448
  # 200 cannot pin itself through later 304s).
449
449
  cycle_log.log(:warn, "Config fetch from #{source} returned a non-envelope 200 (#{e.message}); treating as a failed leg")
450
450
  :failed
451
+ rescue Quonfig::ConfigShape::AllInvalidError => e
452
+ # qfg-krjt: a payload with no loadable config is a leg error, so it can
453
+ # never install as an empty workspace.
454
+ cycle_log.log(:warn, "Config fetch from #{source}: #{e.message}; treating as a failed leg")
455
+ :failed
451
456
  rescue Faraday::ConnectionFailed => e
452
457
  @logger.debug "Connection failure fetching configs from #{source}: #{e.message}"
453
458
  :failed
@@ -472,10 +477,11 @@ module Quonfig
472
477
  data = body.is_a?(String) ? JSON.parse(body) : body
473
478
  raise NonEnvelopeError, 'missing meta.version' unless Quonfig::ConfigEnvelope.wire_envelope?(data)
474
479
 
475
- Quonfig::ConfigEnvelope.new(
476
- configs: data['configs'] || [],
477
- meta: data['meta'] || {}
478
- )
480
+ meta = data['meta'] || {}
481
+ # Skip one malformed config, keep the rest (qfg-krjt). Raises
482
+ # ConfigShape::AllInvalidError when none is loadable.
483
+ configs = Quonfig::ConfigShape.filter(data['configs'] || [], logger: @logger, version: meta['version'])
484
+ Quonfig::ConfigEnvelope.new(configs: configs, meta: meta)
479
485
  end
480
486
 
481
487
  def short_body(response)
@@ -565,18 +571,20 @@ module Quonfig
565
571
  # (Thread#raise, Timeout) then leaves the held generation where it
566
572
  # was, so the same-generation redelivery that follows installs again
567
573
  # and repairs the store instead of being ignored as a duplicate.
574
+ #
575
+ # The new config set is built off to the side and swapped in with one
576
+ # reference write (qfg-i86q), so a concurrent lookup never sees a flag
577
+ # from this envelope with a segment from the last one, or a key that
578
+ # is momentarily absent.
568
579
  unless @store.nil?
569
- new_keys = next_map.keys.to_set
570
- old_keys = @store.keys.to_set
571
- # Drop keys that disappeared server-side.
572
- (old_keys - new_keys).each { |k| @store.delete(k) } if @store.respond_to?(:delete)
573
-
580
+ next_configs = {}
574
581
  envelope.configs.each do |cfg|
575
582
  key = config_key(cfg)
576
583
  next if key.nil?
577
584
 
578
- @store.set(key, cfg)
585
+ next_configs[key] = cfg
579
586
  end
587
+ @store.replace_all(next_configs)
580
588
  end
581
589
 
582
590
  # qfg-9dxb.3 Fix A: an unversioned install (generation <= 0) carries no
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quonfig
4
+ # Decode-time shape check for config entries (qfg-krjt; the WS8
5
+ # skip-one-bad-config rule, as sdk-go 2f6b98b, sdk-node qfg-goi1.10.1 and
6
+ # sdk-python qfg-goi1.10.5).
7
+ #
8
+ # The store keeps raw JSON and the evaluator walks it as Hashes and Arrays,
9
+ # so a config whose rule, criterion or default has the wrong shape, or whose
10
+ # weighted value has a non-integer weight, would install and then raise
11
+ # NoMethodError/TypeError out of every read of that key. Each delivery path
12
+ # (HTTP, SSE, datadir) runs its configs through .filter instead: a bad entry
13
+ # is skipped with a WARN naming its key, so the key reads as not found, and
14
+ # the rest installs. If every entry fails, .filter raises AllInvalidError so
15
+ # the payload is rejected and can never install as an empty workspace.
16
+ module ConfigShape
17
+ LOG = Quonfig::InternalLogger.new(self)
18
+
19
+ # Raised by .filter when a non-empty payload has no loadable config.
20
+ class AllInvalidError < StandardError; end
21
+
22
+ module_function
23
+
24
+ # Returns the configs that pass #problem, in order. +version+ is only used
25
+ # in the log line.
26
+ def filter(configs, logger: nil, version: nil)
27
+ raise AllInvalidError, 'every config failed to load: configs is not a list' unless configs.is_a?(Array)
28
+
29
+ log = logger || LOG
30
+ first_problem = nil
31
+ kept = configs.select do |cfg|
32
+ reason = problem(cfg)
33
+ next true if reason.nil?
34
+
35
+ first_problem ||= reason
36
+ log.warn "[quonfig] skipping config \"#{key_of(cfg)}\" that failed to load (version #{version}): " \
37
+ "#{reason}; other configs are unaffected"
38
+ false
39
+ end
40
+ raise AllInvalidError, "every config failed to load (first: #{first_problem})" if kept.empty? && first_problem
41
+
42
+ kept
43
+ end
44
+
45
+ # nil when +cfg+ has a shape the evaluator can walk, else a short reason.
46
+ def problem(cfg)
47
+ return 'config entry is not an object' unless cfg.is_a?(Hash)
48
+
49
+ key = field(cfg, 'key')
50
+ return 'config entry has no key' unless key.is_a?(String) && !key.empty?
51
+
52
+ default = field(cfg, 'default')
53
+ unless default.nil?
54
+ return 'default is not an object' unless default.is_a?(Hash)
55
+
56
+ reason = rules_problem(field(default, 'rules'), 'default')
57
+ return reason if reason
58
+ end
59
+
60
+ env = field(cfg, 'environment')
61
+ return nil if env.nil?
62
+ return 'environment is not an object' unless env.is_a?(Hash)
63
+
64
+ rules_problem(field(env, 'rules'), 'environment')
65
+ end
66
+
67
+ def rules_problem(rules, where)
68
+ return nil if rules.nil?
69
+ return "#{where}.rules is not a list" unless rules.is_a?(Array)
70
+
71
+ rules.each_with_index do |rule, i|
72
+ reason = rule_problem(rule)
73
+ return "#{where}.rules[#{i}]: #{reason}" if reason
74
+ end
75
+ nil
76
+ end
77
+
78
+ def rule_problem(rule)
79
+ return 'rule is not an object' unless rule.is_a?(Hash)
80
+
81
+ criteria = field(rule, 'criteria')
82
+ unless criteria.nil?
83
+ return 'criteria is not a list' unless criteria.is_a?(Array)
84
+
85
+ criteria.each_with_index do |criterion, i|
86
+ return "criteria[#{i}] is not an object" unless criterion.is_a?(Hash)
87
+
88
+ match = field(criterion, 'valueToMatch')
89
+ return "criteria[#{i}].valueToMatch is not an object" unless match.nil? || match.is_a?(Hash)
90
+ end
91
+ end
92
+
93
+ value_problem(field(rule, 'value'))
94
+ end
95
+
96
+ def value_problem(value)
97
+ return nil if value.nil?
98
+ return 'value is not an object' unless value.is_a?(Hash)
99
+ return nil unless field(value, 'type') == 'weighted_values'
100
+
101
+ weighted = field(value, 'value')
102
+ return 'weighted_values value is not an object' unless weighted.is_a?(Hash)
103
+
104
+ entries = field(weighted, 'weightedValues')
105
+ return nil if entries.nil?
106
+ return 'weightedValues is not a list' unless entries.is_a?(Array)
107
+
108
+ entries.each_with_index do |entry, i|
109
+ return "weightedValues[#{i}] is not an object" unless entry.is_a?(Hash)
110
+
111
+ # A missing weight counts as 0; a present one must be an Integer
112
+ # (sdk-go's int weight fails json.Unmarshal the same way).
113
+ next unless entry.key?('weight') || entry.key?(:weight)
114
+ next if field(entry, 'weight').is_a?(Integer)
115
+
116
+ return "weightedValues[#{i}].weight must be an integer (got #{field(entry, 'weight').class})"
117
+ end
118
+ nil
119
+ end
120
+
121
+ def field(hash, name)
122
+ return hash[name] if hash.key?(name)
123
+
124
+ hash[name.to_sym]
125
+ end
126
+
127
+ def key_of(cfg)
128
+ return '' unless cfg.is_a?(Hash)
129
+
130
+ key = field(cfg, 'key')
131
+ key.is_a?(String) ? key : ''
132
+ end
133
+ end
134
+ end
@@ -3,48 +3,94 @@
3
3
  module Quonfig
4
4
  # In-memory store of configs keyed by config key.
5
5
  #
6
- # Mirrors sdk-node's ConfigStore (src/store.ts). Integration tests and the
7
- # new Resolver/Evaluator trio construct this directly, independent of any
8
- # Client/ConfigLoader plumbing.
6
+ # Integration tests and the Resolver/Evaluator trio construct this
7
+ # directly, independent of any Client/ConfigLoader plumbing.
9
8
  #
10
- # Thread-safety: backed by Concurrent::Map, whose per-key reads, writes, and
11
- # deletes are atomic. There is no compound multi-key operation here that
12
- # needs an outer lock — envelope application in ConfigLoader is a sequence
13
- # of independent set/delete calls, and readers tolerate seeing the
14
- # in-progress mix. Eventual consistency across an envelope is acceptable
15
- # and matches sdk-node behavior.
9
+ # Thread-safety (qfg-i86q): the store holds one frozen Hash snapshot behind
10
+ # an AtomicReference. An install builds the next Hash off to the side and
11
+ # swaps the reference once (#replace_all), so a reader sees the old
12
+ # envelope or the new one, never a mix. Matches sdk-go runtime_store.go.
13
+ # #set / #delete / #clear are copy-on-write under a writer mutex, for the
14
+ # test harness and single-key callers.
15
+ #
16
+ # One lookup can read several keys (a flag, then the segments it
17
+ # references, then a decryptWith key). #pinned holds one snapshot for the
18
+ # current thread for the duration of a block, so every read in that
19
+ # lookup comes from the same envelope even if an install swaps the store
20
+ # mid-evaluation.
16
21
  class ConfigStore
22
+ EMPTY = {}.freeze
23
+ private_constant :EMPTY
24
+
17
25
  def initialize(initial_configs = nil)
18
- @configs = Concurrent::Map.new
26
+ @write_mutex = Mutex.new
27
+ @pin_key = :"__quonfig_store_pin_#{object_id}"
28
+ @snapshot = Concurrent::AtomicReference.new(EMPTY)
19
29
  return unless initial_configs
20
30
 
21
- initial_configs.each { |k, v| @configs[k] = v }
31
+ replace_all(initial_configs.to_h)
22
32
  end
23
33
 
24
34
  def get(key)
25
- @configs[key]
35
+ snapshot[key]
26
36
  end
27
37
 
28
38
  def set(key, config)
29
- @configs[key] = config
39
+ @write_mutex.synchronize do
40
+ @snapshot.set(@snapshot.get.merge(key => config).freeze)
41
+ end
42
+ config
30
43
  end
31
44
 
32
45
  def delete(key)
33
- @configs.delete(key)
46
+ @write_mutex.synchronize do
47
+ current = @snapshot.get
48
+ return nil unless current.key?(key)
49
+
50
+ next_map = current.dup
51
+ value = next_map.delete(key)
52
+ @snapshot.set(next_map.freeze)
53
+ value
54
+ end
34
55
  end
35
56
 
36
57
  def clear
37
- @configs.each_key { |k| @configs.delete(k) }
58
+ replace_all(EMPTY)
59
+ end
60
+
61
+ # Atomically replace every config with +configs+ (a Hash of key =>
62
+ # config). Readers see either the previous set or this one.
63
+ def replace_all(configs)
64
+ next_map = configs.frozen? ? configs : configs.dup.freeze
65
+ @write_mutex.synchronize { @snapshot.set(next_map) }
66
+ nil
38
67
  end
39
68
 
40
69
  def keys
41
- @configs.keys
70
+ snapshot.keys
42
71
  end
43
72
 
44
73
  def all_configs
45
- h = {}
46
- @configs.each_pair { |k, v| h[k] = v }
47
- h
74
+ snapshot.dup
75
+ end
76
+
77
+ # Run the block with this thread's reads pinned to the current snapshot.
78
+ # Re-entrant: a nested call keeps the outer pin.
79
+ def pinned
80
+ return yield if Thread.current[@pin_key]
81
+
82
+ Thread.current[@pin_key] = @snapshot.get
83
+ begin
84
+ yield
85
+ ensure
86
+ Thread.current[@pin_key] = nil
87
+ end
88
+ end
89
+
90
+ private
91
+
92
+ def snapshot
93
+ Thread.current[@pin_key] || @snapshot.get
48
94
  end
49
95
  end
50
96
  end
@@ -50,18 +50,18 @@ module Quonfig
50
50
  end
51
51
  end
52
52
 
53
+ version = "datadir:#{datadir}"
54
+ # Skip one malformed config file, keep the rest (qfg-krjt).
53
55
  Quonfig::ConfigEnvelope.new(
54
- configs: configs,
55
- meta: { 'version' => "datadir:#{datadir}", 'environment' => env_id }
56
+ configs: Quonfig::ConfigShape.filter(configs, version: version),
57
+ meta: { 'version' => version, 'environment' => env_id }
56
58
  )
57
59
  end
58
60
 
59
61
  # Convenience: load the envelope and populate a fresh ConfigStore.
60
62
  def load_store(datadir, environment = nil)
61
63
  envelope = load_envelope(datadir, environment)
62
- store = Quonfig::ConfigStore.new
63
- envelope.configs.each { |cfg| store.set(cfg['key'], cfg) }
64
- store
64
+ Quonfig::ConfigStore.new(envelope.configs.to_h { |cfg| [cfg['key'], cfg] })
65
65
  end
66
66
 
67
67
  def resolve_environment(quonfig_path, environment)
@@ -47,7 +47,33 @@ module Quonfig
47
47
  # the legacy "return nil if absent" shape can rescue and recover (see
48
48
  # IntegrationTestHelpers.assert_resolved, which folds a missing-key
49
49
  # raise into the test's expected default).
50
+ # The whole lookup reads one store snapshot (qfg-i86q): the flag, the
51
+ # segments it references and any decryptWith key all come from the same
52
+ # envelope even if an install lands mid-evaluation.
50
53
  def get(key, context = nil)
54
+ return get_unpinned(key, context) unless @store.respond_to?(:pinned)
55
+
56
+ @store.pinned { get_unpinned(key, context) }
57
+ end
58
+
59
+ # Post-evaluation value resolution. Mirrors sdk-node Resolver#resolveValue
60
+ # and sdk-go resolver.Resolve:
61
+ # - "provided" + ENV_VAR → read ENV[lookup], coerce to config's valueType
62
+ # - confidential + decryptWith → look up the key config, decrypt
63
+ # - everything else passes through unchanged
64
+ def resolve_value(value, config, context = nil, &on_weighted_index)
65
+ resolve_value_on_path(value, config, context, [], &on_weighted_index)
66
+ end
67
+
68
+ # Integration shims for code that expects a ConfigResolver. Keep these
69
+ # narrow; the real ConfigResolver still owns the production hot path.
70
+ def symbolize_json_names?
71
+ false
72
+ end
73
+
74
+ private
75
+
76
+ def get_unpinned(key, context)
51
77
  config = raw(key)
52
78
  raise Quonfig::Errors::MissingDefaultError, key if config.nil?
53
79
 
@@ -75,23 +101,6 @@ module Quonfig
75
101
  )
76
102
  end
77
103
 
78
- # Post-evaluation value resolution. Mirrors sdk-node Resolver#resolveValue
79
- # and sdk-go resolver.Resolve:
80
- # - "provided" + ENV_VAR → read ENV[lookup], coerce to config's valueType
81
- # - confidential + decryptWith → look up the key config, decrypt
82
- # - everything else passes through unchanged
83
- def resolve_value(value, config, context = nil, &on_weighted_index)
84
- resolve_value_on_path(value, config, context, [], &on_weighted_index)
85
- end
86
-
87
- # Integration shims for code that expects a ConfigResolver. Keep these
88
- # narrow; the real ConfigResolver still owns the production hot path.
89
- def symbolize_json_names?
90
- false
91
- end
92
-
93
- private
94
-
95
104
  # resolve_value plus +key_path+: the config keys already being resolved
96
105
  # above this one through decryptWith, so a decryptWith cycle raises a
97
106
  # DecryptionError instead of recursing until SystemStackError
@@ -34,10 +34,12 @@ module Quonfig
34
34
  #
35
35
  # sse_initial_reconnect_delay / sse_max_reconnect_delay: backoff bounds.
36
36
  # Each failed reconnect doubles the delay (with +/-50% jitter) up to the
37
- # max. A successful event delivery resets the delay to the initial
38
- # value — matches sdk-python's policy. A clean server-initiated FIN is
39
- # treated as "not a failure for backoff purposes" because LBs recycling
40
- # connections is normal; the reconnect counter still increments.
37
+ # max. A connection that delivers its first valid frame (a config
38
+ # envelope or a keepalive) resets the delay to the initial value. A
39
+ # clean server-initiated FIN after such a frame is "not a failure for
40
+ # backoff purposes" because LBs recycling connections is normal; the
41
+ # reconnect counter still increments. A connection that ends before any
42
+ # frame is a failed attempt (qfg-goi1.10.43).
41
43
  def initialize(sse_read_timeout: 90,
42
44
  sse_connect_timeout: 10,
43
45
  sse_initial_reconnect_delay: 1.0,
@@ -76,13 +78,22 @@ module Quonfig
76
78
 
77
79
  # +on_error+: optional callable invoked on every SSE error edge. Parent
78
80
  # Quonfig::Client wires this to drive @sse_state -> :error so that
79
- # +connection_state+ reflects the disconnect (qfg-47c2.27).
80
- def initialize(prefab_options, config_loader, options = nil, logger = nil, on_error: nil)
81
+ # +connection_state+ reflects the disconnect (qfg-47c2.27). A connection
82
+ # that ends before its first valid frame, clean EOF included, is an error
83
+ # edge too (qfg-goi1.10.43).
84
+ #
85
+ # +on_connected+: optional callable invoked once per connection, at its
86
+ # first valid frame (a keepalive, or a data block that validates as a
87
+ # config envelope), before that frame's envelope reaches +on_envelope+.
88
+ # Never on the 200 headers alone (supervisor-test-contract.md "Rule — a
89
+ # connection is `connected` at its first valid frame").
90
+ def initialize(prefab_options, config_loader, options = nil, logger = nil, on_error: nil, on_connected: nil)
81
91
  @prefab_options = prefab_options
82
92
  @options = options || Options.new
83
93
  @config_loader = config_loader
84
94
  @logger = logger || LOG
85
95
  @on_error = on_error
96
+ @on_connected = on_connected
86
97
 
87
98
  @stopped = Concurrent::AtomicBoolean.new(false)
88
99
  @restart_total = 0
@@ -234,10 +245,18 @@ module Quonfig
234
245
  end
235
246
  first_attempt = false
236
247
 
237
- connected_at_least_once = false
248
+ # First-frame rule (qfg-goi1.10.43): this connection counts as
249
+ # connected only at its first valid frame. Raised once, from the
250
+ # parser, before the frame's envelope is installed.
251
+ frame_seen = false
252
+ on_frame = lambda do
253
+ next if frame_seen
254
+
255
+ frame_seen = true
256
+ invoke_on_connected
257
+ end
238
258
  begin
239
- stream_once do |event|
240
- connected_at_least_once = true
259
+ ended_url = stream_once(on_frame) do |event|
241
260
  # Persist the most recent id so the next reconnect resumes
242
261
  # from there via Last-Event-Id. Updated *before* the user
243
262
  # callback runs so a raising listener still advances the
@@ -248,22 +267,30 @@ module Quonfig
248
267
  # listener must not look like a transport error and trigger
249
268
  # a reconnect.
250
269
  invoke_on_envelope_safely(on_envelope, event)
251
- # A connection healthy enough to deliver a real envelope
252
- # earns a reset of the backoff. Sustained outages never
253
- # reach this branch (no event ever delivered) so the
254
- # exponential growth still holds.
255
- delay = @options.sse_initial_reconnect_delay
256
270
  end
271
+ # A clean EOF before any frame (200 headers then close) is a
272
+ # failed attempt, exactly like a connect error: report it so
273
+ # the client leaves :connected and arms the fallback poller.
274
+ # This is what bounds ruby's transparent reconnect: connected
275
+ # survives a clean-EOF reconnect only until the next connection
276
+ # ends frameless.
277
+ handle_frameless_end(ended_url) unless frame_seen || @stopped.value
257
278
  rescue StandardError => e
258
279
  handle_error(e) unless @stopped.value
259
280
  end
260
281
 
261
- # Backoff only grows on failed connect attempts. A server-
262
- # initiated clean FIN after a healthy session (normal LB
263
- # recycling) reuses the same delay — punishing it would make
264
- # us look broken under benign rolling restarts. Matches
265
- # sdk-go's `connectedOK` distinction.
266
- delay = [delay * 2, @options.sse_max_reconnect_delay].min unless connected_at_least_once
282
+ # The backoff resets only for a connection that delivered a
283
+ # frame; it then reconnects at the initial delay even after a
284
+ # clean FIN (normal LB recycling) or an IO error later in the
285
+ # session. Every other attempt (connect error, non-200, or a
286
+ # frameless 200) doubles it. Sustained outages never deliver a
287
+ # frame, so the exponential growth still holds. Matches sdk-go's
288
+ # connectOnce "frame seen" result.
289
+ delay = if frame_seen
290
+ @options.sse_initial_reconnect_delay
291
+ else
292
+ [delay * 2, @options.sse_max_reconnect_delay].min
293
+ end
267
294
  rescue SSEReadDeadlineExceeded => e
268
295
  # Paranoid backstop (qfg-tj18). A watchdog raise that landed
269
296
  # outside +stream_once+ — typically in +interruptible_sleep+
@@ -279,7 +306,8 @@ module Quonfig
279
306
 
280
307
  # Opens one SSE request and yields each parsed event until the stream
281
308
  # ends (clean FIN, error, or stop). Raises on transport errors so the
282
- # caller can apply backoff. Clean FIN returns without raising.
309
+ # caller can apply backoff. Clean FIN returns the URL without raising.
310
+ # +on_frame+ is called at every valid frame (see EventParser#feed).
283
311
  #
284
312
  # A watchdog thread closes the socket if no bytes arrive within
285
313
  # +sse_read_timeout+. Net::HTTP#read_timeout is NOT reliable for the
@@ -288,7 +316,7 @@ module Quonfig
288
316
  # against a configured deadline). sdk-go and sdk-node hit the same
289
317
  # gotcha and solve it the same way: per-chunk reset, async close on
290
318
  # expiry (chaos scenario 02 — sse_silent_stall).
291
- def stream_once(&block)
319
+ def stream_once(on_frame = nil, &block)
292
320
  base = current_url
293
321
  # f05 latch: record if this connection attempt targets a non-primary
294
322
  # stream leg. With the pin in #current_url this is structurally false;
@@ -333,9 +361,14 @@ module Quonfig
333
361
  Thread.handle_interrupt(SSEReadDeadlineExceeded => :immediate) do
334
362
  resp.read_body do |chunk|
335
363
  watchdog.reset!
336
- break if @stopped.value
337
-
338
- parser.feed(chunk, &block)
364
+ # Never +break+ here (qfg-yrly): a break returns into
365
+ # Net::HTTPResponse#reading_body, which then calls +body+ to
366
+ # drain the rest of the response with no block. On an endless
367
+ # SSE stream that blocks the worker forever. Raising skips the
368
+ # drain, and Net::HTTP closes the socket on the way out.
369
+ raise SSEStopRequested if @stopped.value
370
+
371
+ parser.feed(chunk, on_frame: on_frame, &block)
339
372
  end
340
373
  end
341
374
  # read_body returned cleanly — either a server-initiated FIN, or
@@ -344,6 +377,9 @@ module Quonfig
344
377
  # next iteration.
345
378
  @logger.debug "SSE stream ended for url #{url}"
346
379
  end
380
+ url
381
+ rescue SSEStopRequested
382
+ url
347
383
  ensure
348
384
  watchdog.stop
349
385
  register_active(nil)
@@ -394,6 +430,25 @@ module Quonfig
394
430
  @restart_mutex.synchronize { @restart_total += 1 }
395
431
  end
396
432
 
433
+ # A connection that returned 200 and ended cleanly before its first valid
434
+ # frame (qfg-goi1.10.43).
435
+ def handle_frameless_end(url)
436
+ err = SSEFramelessSessionError.new
437
+ @logger.warn "SSE Streaming Error: stream for url #{url} ended before its first frame; " \
438
+ 'treating it as a failed attempt'
439
+ invoke_on_error(err)
440
+ end
441
+
442
+ def invoke_on_connected
443
+ return unless @on_connected
444
+
445
+ begin
446
+ @on_connected.call
447
+ rescue StandardError => e
448
+ @logger.error "SSE on_connected callback raised: #{e.inspect}"
449
+ end
450
+ end
451
+
397
452
  def handle_error(error)
398
453
  if error.is_a?(SSEHTTPAuthError)
399
454
  # Already logged once by check_response_status! (R5).
@@ -478,11 +533,25 @@ module Quonfig
478
533
  # no longer stops on these statuses.
479
534
  SSEHTTPTerminalError = SSEHTTPAuthError # rubocop:disable Lint/UselessConstantScoping
480
535
 
536
+ # Passed to on_error when a 200 stream ends cleanly before its first valid
537
+ # frame (qfg-goi1.10.43). Not raised.
538
+ class SSEFramelessSessionError < StandardError
539
+ def initialize(msg = 'SSE stream ended before its first frame')
540
+ super
541
+ end
542
+ end
543
+
481
544
  # Raised by the watchdog into the worker thread when the per-chunk
482
545
  # read deadline elapses. Caught by run_loop's rescue, indistinguishable
483
546
  # from any other transport error for backoff/restart purposes.
484
547
  class SSEReadDeadlineExceeded < StandardError; end
485
548
 
549
+ # Private: raised from inside +read_body+ when +close+ has been called,
550
+ # so the worker leaves the stream without Net::HTTP draining the body
551
+ # (qfg-yrly). Rescued in +stream_once+; never reaches callers.
552
+ class SSEStopRequested < StandardError; end
553
+ private_constant :SSEStopRequested
554
+
486
555
  # Background watchdog that interrupts the worker thread if no chunk
487
556
  # arrives within +deadline_s+ seconds. Uses Thread#raise — the only
488
557
  # reliable cross-platform way to unblock a Ruby thread blocked in
@@ -560,7 +629,7 @@ module Quonfig
560
629
  # - chunks that split a line mid-way
561
630
  # - any of CR / LF / CRLF as line terminators
562
631
  # - +data:+, +data: + (optional space per SSE spec)
563
- # - +:comment+ lines (keepalives — ignored)
632
+ # - +:comment+ lines (keepalives; a comment-only block is a frame)
564
633
  # - multi-line +data:+ (concatenated with +\n+, per spec)
565
634
  # Ignores +event:+ and +retry:+ — api-delivery does not emit them and the
566
635
  # Quonfig wire contract does not honor reconnect-time directives.
@@ -573,16 +642,34 @@ module Quonfig
573
642
  @data = +''
574
643
  @have_data = false
575
644
  @id = nil
645
+ @saw_comment = false
646
+ @saw_field = false
576
647
  end
577
648
 
578
- def feed(chunk)
649
+ # Yields each config envelope. +on_frame+ (optional) is called at every
650
+ # valid frame, before that frame's envelope is yielded. A valid frame is
651
+ # a block ended by a blank line that is either comment-only (the
652
+ # +: keepalive+ heartbeat; +:+ alone counts) or carries +data:+ that
653
+ # decodes and validates as a config envelope. A block cut off by EOF, a
654
+ # malformed or non-envelope data block, an +id:+-only block and a bare
655
+ # blank line are not frames (qfg-goi1.10.43).
656
+ def feed(chunk, on_frame: nil)
579
657
  @reader.feed(chunk) do |line|
580
658
  if line.empty?
659
+ comment_only = @saw_comment && !@saw_field
660
+ @saw_comment = false
661
+ @saw_field = false
581
662
  event = flush
582
- yield event if event
663
+ if event
664
+ on_frame&.call
665
+ yield event
666
+ elsif comment_only
667
+ on_frame&.call
668
+ end
583
669
  elsif line.start_with?(':')
584
- # comment / keepalive — ignore
670
+ @saw_comment = true
585
671
  else
672
+ @saw_field = true
586
673
  process_field(line)
587
674
  end
588
675
  end
@@ -637,10 +724,18 @@ module Quonfig
637
724
  return nil
638
725
  end
639
726
 
640
- envelope = Quonfig::ConfigEnvelope.new(
641
- configs: parsed['configs'] || [],
642
- meta: parsed['meta'] || {}
643
- )
727
+ meta = parsed['meta'] || {}
728
+ # Skip one malformed config, keep the rest (qfg-krjt); an event with
729
+ # no loadable config is dropped like malformed JSON.
730
+ begin
731
+ configs = Quonfig::ConfigShape.filter(parsed['configs'] || [], logger: @logger || LOG,
732
+ version: meta['version'])
733
+ rescue Quonfig::ConfigShape::AllInvalidError => e
734
+ (@logger || LOG).error "SSE Streaming Error: #{e.message}; event dropped"
735
+ return nil
736
+ end
737
+
738
+ envelope = Quonfig::ConfigEnvelope.new(configs: configs, meta: meta)
644
739
  StreamEvent.new(envelope, id, data)
645
740
  end
646
741
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Quonfig
4
- VERSION = '1.8.0'
4
+ VERSION = '1.9.0'
5
5
  end
@@ -43,7 +43,9 @@ module Quonfig
43
43
  private
44
44
 
45
45
  def weight_of(variant)
46
- variant[:weight] || variant['weight']
46
+ # A missing weight counts as 0 (qfg-krjt); a present one is checked to
47
+ # be an Integer at decode (ConfigShape).
48
+ variant[:weight] || variant['weight'] || 0
47
49
  end
48
50
  end
49
51
  end
data/lib/quonfig.rb CHANGED
@@ -50,6 +50,7 @@ require 'quonfig/config_store'
50
50
  require 'quonfig/evaluator'
51
51
  require 'quonfig/resolver'
52
52
  require 'quonfig/config_envelope'
53
+ require 'quonfig/config_shape'
53
54
  require 'quonfig/config_loader'
54
55
  require 'quonfig/datadir'
55
56
  require 'listen'
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.8.0
4
+ version: 1.9.0
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-10-16 00:00:00.000000000 Z
11
+ date: 2026-10-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -90,6 +90,7 @@ files:
90
90
  - lib/quonfig/client.rb
91
91
  - lib/quonfig/config_envelope.rb
92
92
  - lib/quonfig/config_loader.rb
93
+ - lib/quonfig/config_shape.rb
93
94
  - lib/quonfig/config_store.rb
94
95
  - lib/quonfig/context.rb
95
96
  - lib/quonfig/datadir.rb