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 +4 -4
- data/CHANGELOG.md +15 -0
- data/lib/quonfig/client.rb +12 -5
- data/lib/quonfig/config_loader.rb +18 -10
- data/lib/quonfig/config_shape.rb +134 -0
- data/lib/quonfig/config_store.rb +65 -19
- data/lib/quonfig/datadir.rb +5 -5
- data/lib/quonfig/resolver.rb +26 -17
- data/lib/quonfig/sse_config_client.rb +128 -33
- data/lib/quonfig/version.rb +1 -1
- data/lib/quonfig/weighted_value_resolver.rb +3 -1
- data/lib/quonfig.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 06b78bb9ba3c5950d54b844388ca57e91c23c7bf4cb595c82c57738e807a1716
|
|
4
|
+
data.tar.gz: be216cc0d6ea8cee975bc799b94ac2f5a97829dae7fcb4056fded44835deb918
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
data/lib/quonfig/client.rb
CHANGED
|
@@ -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
|
-
|
|
1277
|
-
|
|
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
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/quonfig/config_store.rb
CHANGED
|
@@ -3,48 +3,94 @@
|
|
|
3
3
|
module Quonfig
|
|
4
4
|
# In-memory store of configs keyed by config key.
|
|
5
5
|
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
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:
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
# and
|
|
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
|
-
@
|
|
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.
|
|
31
|
+
replace_all(initial_configs.to_h)
|
|
22
32
|
end
|
|
23
33
|
|
|
24
34
|
def get(key)
|
|
25
|
-
|
|
35
|
+
snapshot[key]
|
|
26
36
|
end
|
|
27
37
|
|
|
28
38
|
def set(key, config)
|
|
29
|
-
@
|
|
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
|
-
@
|
|
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
|
-
|
|
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
|
-
|
|
70
|
+
snapshot.keys
|
|
42
71
|
end
|
|
43
72
|
|
|
44
73
|
def all_configs
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
data/lib/quonfig/datadir.rb
CHANGED
|
@@ -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' =>
|
|
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
|
-
|
|
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)
|
data/lib/quonfig/resolver.rb
CHANGED
|
@@ -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
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
# connections is normal; the
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
262
|
-
#
|
|
263
|
-
# recycling)
|
|
264
|
-
#
|
|
265
|
-
#
|
|
266
|
-
|
|
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
|
|
337
|
-
|
|
338
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
641
|
-
|
|
642
|
-
|
|
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
|
data/lib/quonfig/version.rb
CHANGED
|
@@ -43,7 +43,9 @@ module Quonfig
|
|
|
43
43
|
private
|
|
44
44
|
|
|
45
45
|
def weight_of(variant)
|
|
46
|
-
|
|
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
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: quonfig
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.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-
|
|
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
|