quonfig 1.6.0 → 1.7.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 +19 -0
- data/README.md +10 -2
- data/lib/quonfig/bound_client.rb +15 -7
- data/lib/quonfig/client.rb +124 -15
- data/lib/quonfig/duration.rb +56 -7
- data/lib/quonfig/evaluator.rb +3 -2
- data/lib/quonfig/murmer3.rb +7 -3
- data/lib/quonfig/reason.rb +2 -2
- data/lib/quonfig/resolver.rb +5 -0
- 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: 1b22d7d46b27ff2ab1452b41eaa68940d886216aaeb16c61a3faddf0778d352c
|
|
4
|
+
data.tar.gz: 0ca1f8e621bcfcd839a28d1d754560b5cf023eff205b24b83049196e64a8e4b0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 720d2acbfe8d30a785c1197fa319d608b1b4e11a1b60657b23d7b3dd921ed0c4882c99e7446b469a009552f09ca54538482509d9dc6a6968dc11df6f0e42d7c2
|
|
7
|
+
data.tar.gz: 11a8f0fbb7851dba082a46a7b657e9ec49aa61ae4af155cf9bf2272a35a3e5f75fa8148acdf0f3df8b936798cae5d8619dbc86bb981199f5bf29ceadf5f79567
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.7.1 - 2026-10-03
|
|
4
|
+
|
|
5
|
+
- **Behaviour change (bug fix): weighted rollouts now hash non-ASCII text the same way as every other Quonfig SDK (qfg-1mvb).** The MurmurHash3 used to pick a weighted variant counted characters instead of UTF-8 bytes, so a config key or hash value containing non-ASCII text (for example `josé`, `日本語`, `🚀`) hashed to a different number than sdk-java, sdk-python, sdk-go and sdk-node. It now hashes the UTF-8 bytes. Ruby users whose hash value (or the flag's key) contains non-ASCII characters may move to a different variant on upgrade; afterwards they get the same variant as in the other SDKs. ASCII-only values and keys are unchanged.
|
|
6
|
+
- **Fix: `Quonfig::Reason.compute` now returns `:SPLIT` for weighted bucket 0 (qfg-mn8f).** It returned `:SPLIT` only for buckets 1 and up, so bucket 0 was reported as `:DEFAULT`/`:RULE_MATCH`. The SDK's own evaluation path does not use this method and already reported bucket 0 as `SPLIT`; this only affects code that calls `Quonfig::Reason.compute` directly.
|
|
7
|
+
|
|
8
|
+
## 1.7.0 - 2026-10-02
|
|
9
|
+
|
|
10
|
+
- **New: public `Client#get_or_raise` and `Client#get_duration_details` (also on `BoundClient`) (qfg-2agi.27).** `get_or_raise(key, default:, context:)` returns the value like `get`; a missing key with no `default:` raises `MissingDefaultError` even under `on_no_default: :return_nil`, and an unset ENV_VAR (`MissingEnvVarError`), an uncoercible ENV_VAR or malformed stored duration (`EnvVarParseError`) or a decryption failure (`DecryptionError`) raises. `get_duration_details` returns integer milliseconds in `EvaluationDetails`; a non-duration config is `ERROR`/`TYPE_MISMATCH` and a malformed value is `ERROR` with a nil value. The shared integration suite's init-timeout `get_or_raise` case now calls the public method instead of falling back to `get`.
|
|
11
|
+
- **Fix: durations follow the one grammar all Quonfig SDKs share, and a malformed duration returns the default instead of a made-up number (qfg-2agi.10).** Grammar (integration-test-data `tests/duration/grammar.yaml`): `P[nD][T[nH][nM][n[.fff]S]]`, at least one component, a fraction only on seconds (at most 9 digits), at most `P36500D`. Before, the pattern was unanchored, so `xxPT5Sxx` read as 5000 ms, `PT5M3H` as 300000 ms and `garbage` as 0. Milliseconds are now rounded half up on both the stored and the ENV_VAR path (`PT1.9999S` from an env var was 1999, now 2000).
|
|
12
|
+
- `get_duration` on a malformed value (stored or ENV_VAR) returns `default:` and logs one warning per key. With no default it returns `nil` under `on_no_default: :return_nil` and raises `Quonfig::Errors::EnvVarParseError` under the default `:raise` policy.
|
|
13
|
+
- `get_duration` on a config whose value type is not duration now raises `TypeMismatchError`, like `get_int` on a string config. Before, an int config's number was returned as milliseconds.
|
|
14
|
+
- `get` on a malformed stored duration returns `nil` (was `0` or a wrong number).
|
|
15
|
+
- `Quonfig::Duration.parse` raises `ArgumentError` on an invalid string (it returned `0`). New `Quonfig::Duration.parse_millis` (Integer ms or `nil`) and `Quonfig::Duration.valid?`. Values that used fractions on D/H/M (`PT0.5H`, `PT1.5M`) are now invalid; write `PT30M`, `PT90S`.
|
|
16
|
+
- **Fix: `Client#get` on a duration provided by an ENV_VAR now returns integer milliseconds, the same as a stored duration (qfg-2agi.22).** Before, it returned the raw ISO string (`PT30M` came back as `"PT30M"` from the env var but `1800000` when stored). `get_duration` was already correct.
|
|
17
|
+
- **Behaviour change (bug fix): a named context from a newer tier now replaces the whole same-named context from an older tier, instead of being merged into it property by property (qfg-2agi.36).** Tiers, oldest to newest: `global_context`, `with_context` / `in_context` scopes (outer to inner), per-call context. Example: with `global_context: { user: { 'role' => 'admin' } }`, a call passing `user: { 'plan' => 'pro' }` now evaluates with `user.plan` only; `user.role` is no longer present. Named contexts the newer tier does not mention (for example `team`) are still kept. A symbol name and a string name (`:user` vs `'user'`) count as the same named context. This is the documented rule. Callers that relied on the old property-level merge should pass every attribute of a named context they set.
|
|
18
|
+
- **Fix (security): evaluation-summary telemetry no longer sends the plaintext of `confidential` or `decryptWith` config values (qfg-2agi.15).** `selectedValue` now carries the `*****<hash>` redacted form the resolver already computed; before, `Client#get` reported the decrypted value. Non-confidential values are reported unchanged.
|
|
19
|
+
- **Tests: the shared integration suite now drives every case through the public `Quonfig::Client` (qfg-2agi.33).** Each case uses a datadir client and the typed getter named by its `type:` (`get_string`, `get_int`, `get_float`, `get_bool`, `get_string_list`, `get_json`, `get_duration`), `enabled?` or `get_or_raise`. Context tiers go through `global_context:`, `with_context` and `in_context`. Telemetry cases flush the client's real reporter to a local HTTP server and check the POST. Before, most cases called the internal resolver, and the harness redacted telemetry values itself, so a broken typed getter, a negated `enabled?` or dropped redaction left the suite green. No library change.
|
|
20
|
+
- **Tests: the shared integration suite now checks every duration case through the public `Client#get_duration`, with an exact integer millisecond comparison (qfg-2agi.4).** Before, it checked the internal resolver, so a bug in `get_duration` could not fail the suite. No library change.
|
|
21
|
+
|
|
3
22
|
## 1.6.0 - 2026-09-28
|
|
4
23
|
|
|
5
24
|
- **Change: a weighted rollout that hashes on a property missing from the context now hashes an empty value, so every such caller gets the same variant for that flag (qfg-9dxb.8).** Before, such evaluations got a random variant on every call. It is the same variant a context with the property set to `""` gets, and a variant with weight 0 is not served. The reason is still `SPLIT`. The `flag_metadata` of `get_*_details` results includes `'hashPropertyMissing' => true` when the property is missing, and the SDK logs one warning per flag. A weighted rollout with no hash property configured still picks a random variant on every evaluation, unchanged. Users whose context has the property keep the same variant as in 1.5.0.
|
data/README.md
CHANGED
|
@@ -259,7 +259,7 @@ Quonfig::Client.new(
|
|
|
259
259
|
fallback_poll_enabled: true,
|
|
260
260
|
fallback_poll_interval_ms: 60_000,
|
|
261
261
|
init_timeout_ms: 10_000,
|
|
262
|
-
on_no_default: :
|
|
262
|
+
on_no_default: :raise,
|
|
263
263
|
global_context: {},
|
|
264
264
|
datadir: '/path/to/workspace',
|
|
265
265
|
environment: 'production',
|
|
@@ -277,7 +277,7 @@ Quonfig::Client.new(
|
|
|
277
277
|
| `fallback_poll_enabled` | `Boolean` | `true` | Engage HTTP polling as a fallback when SSE is unavailable for >= 2x `fallback_poll_interval_ms`. Deprecated alias: `enable_polling`. |
|
|
278
278
|
| `fallback_poll_interval_ms` | `Integer` (ms) | `60_000` | Interval between fallback HTTP polls, in milliseconds. Deprecated alias: `poll_interval` (seconds, multiplied by 1000 internally). |
|
|
279
279
|
| `init_timeout_ms` | `Integer` (ms) | `10_000` | Maximum time to wait for the initial config load, in milliseconds. Deprecated alias: `initialization_timeout_sec` (seconds, multiplied by 1000 internally). |
|
|
280
|
-
| `on_no_default` | `Symbol` | `:
|
|
280
|
+
| `on_no_default` | `Symbol` | `:raise` | Behavior when a key has no value and no default: `:raise` (raise `Quonfig::Errors::MissingDefaultError`) or `:return_nil`. |
|
|
281
281
|
| `global_context` | `Hash` | `{}` | Context applied to every evaluation. |
|
|
282
282
|
| `datadir` | `String` | `ENV['QUONFIG_DIR']` | Path to a local workspace. When set, the SDK runs offline from disk. |
|
|
283
283
|
| `environment` | `String` | `ENV['QUONFIG_ENVIRONMENT']` | Environment to evaluate in datadir mode. Required when `datadir` is set. |
|
|
@@ -361,6 +361,14 @@ client.get_json('homepage.layout')
|
|
|
361
361
|
client.enabled?('beta-feature', user: { key: 'user-123' })
|
|
362
362
|
```
|
|
363
363
|
|
|
364
|
+
`get_or_raise(key, default:, context:)` returns the value like `get`, but a
|
|
365
|
+
missing key without a `default:` always raises
|
|
366
|
+
`Quonfig::Errors::MissingDefaultError` (whatever `on_no_default` is set to), and
|
|
367
|
+
an unset or malformed ENV_VAR value, a malformed stored duration, or a
|
|
368
|
+
decryption failure raises its error. `get_duration_details(key, context:)`
|
|
369
|
+
returns `Quonfig::EvaluationDetails` with the duration in integer milliseconds,
|
|
370
|
+
like the other `*_details` getters.
|
|
371
|
+
|
|
364
372
|
## Dynamic log levels (SemanticLogger)
|
|
365
373
|
|
|
366
374
|
Quonfig can drive per-class log levels at runtime. Set config keys like
|
data/lib/quonfig/bound_client.rb
CHANGED
|
@@ -40,6 +40,10 @@ module Quonfig
|
|
|
40
40
|
@client.get_json(key, default: default, context: @context)
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
+
def get_or_raise(key, default: NO_DEFAULT_PROVIDED)
|
|
44
|
+
@client.get_or_raise(key, default: default, context: @context)
|
|
45
|
+
end
|
|
46
|
+
|
|
43
47
|
# ---- Details getters ----------------------------------------------
|
|
44
48
|
|
|
45
49
|
def get_bool_details(key)
|
|
@@ -66,15 +70,18 @@ module Quonfig
|
|
|
66
70
|
@client.get_json_details(key, context: @context)
|
|
67
71
|
end
|
|
68
72
|
|
|
73
|
+
def get_duration_details(key)
|
|
74
|
+
@client.get_duration_details(key, context: @context)
|
|
75
|
+
end
|
|
76
|
+
|
|
69
77
|
def enabled?(feature_name)
|
|
70
78
|
@client.enabled?(feature_name, @context)
|
|
71
79
|
end
|
|
72
80
|
|
|
73
|
-
# Returns a new BoundClient whose bound context is
|
|
74
|
-
#
|
|
75
|
-
# context (
|
|
76
|
-
#
|
|
77
|
-
# preserved.
|
|
81
|
+
# Returns a new BoundClient whose bound context is this bound context
|
|
82
|
+
# plus +additional+ (qfg-2agi.24): a named context in +additional+
|
|
83
|
+
# REPLACES the whole same-named context (no per-property merge); named
|
|
84
|
+
# contexts it does not mention are kept. Names compare as strings.
|
|
78
85
|
def in_context(additional)
|
|
79
86
|
self.class.new(@client, merge_contexts(@context, additional || {}))
|
|
80
87
|
end
|
|
@@ -87,9 +94,10 @@ module Quonfig
|
|
|
87
94
|
|
|
88
95
|
def merge_contexts(left, right)
|
|
89
96
|
merged = {}
|
|
90
|
-
left.each
|
|
97
|
+
left.each { |name, ctx| merged[name] = ctx.dup }
|
|
91
98
|
right.each do |name, ctx|
|
|
92
|
-
merged
|
|
99
|
+
merged.delete_if { |existing, _| existing.to_s == name.to_s }
|
|
100
|
+
merged[name] = ctx.dup
|
|
93
101
|
end
|
|
94
102
|
merged
|
|
95
103
|
end
|
data/lib/quonfig/client.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'json'
|
|
4
4
|
require 'timeout'
|
|
5
|
+
require 'set'
|
|
5
6
|
|
|
6
7
|
module Quonfig
|
|
7
8
|
# Public Quonfig SDK client.
|
|
@@ -63,6 +64,9 @@ module Quonfig
|
|
|
63
64
|
@evaluator = Quonfig::Evaluator.new(@store, env_id: @options.environment)
|
|
64
65
|
@resolver = Quonfig::Resolver.new(@store, @evaluator)
|
|
65
66
|
@semantic_logger_filters = {}
|
|
67
|
+
# Keys already warned about for a malformed duration value (qfg-2agi.10).
|
|
68
|
+
@malformed_duration_warned = Set.new
|
|
69
|
+
@malformed_duration_mutex = Mutex.new
|
|
66
70
|
@sse_client = nil
|
|
67
71
|
@poll_supervisor = nil
|
|
68
72
|
@stopped = false
|
|
@@ -161,14 +165,70 @@ module Quonfig
|
|
|
161
165
|
typed_get(key, :string_list, default: default, context: context)
|
|
162
166
|
end
|
|
163
167
|
|
|
168
|
+
# Integer milliseconds for a duration config (qfg-2agi.10).
|
|
169
|
+
#
|
|
170
|
+
# - A config whose valueType is not duration raises TypeMismatchError,
|
|
171
|
+
# like get_int on a string config.
|
|
172
|
+
# - Values follow the shared grammar (Quonfig::Duration); ms are rounded
|
|
173
|
+
# half up on both the stored and the ENV_VAR path.
|
|
174
|
+
# - A malformed value (stored or ENV_VAR) returns +default+ and logs one
|
|
175
|
+
# warning per key. With no default it returns nil under
|
|
176
|
+
# on_no_default: :return_nil and raises EnvVarParseError under :raise.
|
|
164
177
|
def get_duration(key, default: NO_DEFAULT_PROVIDED, context: NO_DEFAULT_PROVIDED)
|
|
165
|
-
|
|
178
|
+
ensure_initialized_after_fork
|
|
179
|
+
ctx = build_context(context)
|
|
180
|
+
record_context_for_telemetry(ctx)
|
|
181
|
+
result =
|
|
182
|
+
begin
|
|
183
|
+
@resolver.get(key, ctx)
|
|
184
|
+
rescue Quonfig::Errors::MissingDefaultError
|
|
185
|
+
nil
|
|
186
|
+
rescue Quonfig::Errors::EnvVarParseError => e
|
|
187
|
+
return malformed_duration(key, default, e)
|
|
188
|
+
end
|
|
189
|
+
return handle_missing(key, default) if result.nil?
|
|
190
|
+
|
|
191
|
+
record_evaluation_for_telemetry(result)
|
|
192
|
+
duration_millis(key, result, default)
|
|
166
193
|
end
|
|
167
194
|
|
|
168
195
|
def get_json(key, default: NO_DEFAULT_PROVIDED, context: NO_DEFAULT_PROVIDED)
|
|
169
196
|
typed_get(key, :json, default: default, context: context)
|
|
170
197
|
end
|
|
171
198
|
|
|
199
|
+
# Like +get+, but never applies the +on_no_default+ policy (qfg-2agi.27):
|
|
200
|
+
#
|
|
201
|
+
# - A missing key (or a config with no matching value) returns +default:+
|
|
202
|
+
# when one is given and otherwise raises MissingDefaultError, even
|
|
203
|
+
# under on_no_default: :return_nil.
|
|
204
|
+
# - A config provided by an unset ENV_VAR raises MissingEnvVarError; an
|
|
205
|
+
# ENV_VAR value that cannot be coerced to the config's type raises
|
|
206
|
+
# EnvVarParseError; a decryption failure raises DecryptionError.
|
|
207
|
+
# - A malformed stored duration raises EnvVarParseError (a given
|
|
208
|
+
# +default:+ is not applied; it only covers a missing key).
|
|
209
|
+
def get_or_raise(key, default: NO_DEFAULT_PROVIDED, context: NO_DEFAULT_PROVIDED)
|
|
210
|
+
ensure_initialized_after_fork
|
|
211
|
+
ctx = build_context(context)
|
|
212
|
+
record_context_for_telemetry(ctx)
|
|
213
|
+
result =
|
|
214
|
+
begin
|
|
215
|
+
@resolver.get(key, ctx)
|
|
216
|
+
rescue Quonfig::Errors::MissingDefaultError
|
|
217
|
+
nil
|
|
218
|
+
end
|
|
219
|
+
if result.nil?
|
|
220
|
+
raise Quonfig::Errors::MissingDefaultError, key if default == NO_DEFAULT_PROVIDED
|
|
221
|
+
|
|
222
|
+
return default
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
record_evaluation_for_telemetry(result)
|
|
226
|
+
value = result.unwrapped_value
|
|
227
|
+
raise malformed_stored_duration_error(result) if malformed_stored_duration?(result, value)
|
|
228
|
+
|
|
229
|
+
value
|
|
230
|
+
end
|
|
231
|
+
|
|
172
232
|
# ---- Details getters ----------------------------------------------
|
|
173
233
|
#
|
|
174
234
|
# Mirrors the typed getters above but returns a +Quonfig::EvaluationDetails+
|
|
@@ -201,6 +261,13 @@ module Quonfig
|
|
|
201
261
|
evaluate_details(key, :json, context)
|
|
202
262
|
end
|
|
203
263
|
|
|
264
|
+
# Integer milliseconds in +value+ (qfg-2agi.27). A config whose valueType
|
|
265
|
+
# is not duration is ERROR / TYPE_MISMATCH; a malformed value (stored or
|
|
266
|
+
# ENV_VAR) is ERROR with a nil value.
|
|
267
|
+
def get_duration_details(key, context: NO_DEFAULT_PROVIDED)
|
|
268
|
+
evaluate_details(key, :duration, context)
|
|
269
|
+
end
|
|
270
|
+
|
|
204
271
|
def enabled?(feature_name, jit_context = NO_DEFAULT_PROVIDED)
|
|
205
272
|
value = get(feature_name, false, jit_context)
|
|
206
273
|
[true, 'true'].include?(value)
|
|
@@ -1153,7 +1220,9 @@ module Quonfig
|
|
|
1153
1220
|
config_type: config_field(config, :type),
|
|
1154
1221
|
conditional_value_index: result.rule_index,
|
|
1155
1222
|
weighted_value_index: result.weighted_value_index,
|
|
1156
|
-
|
|
1223
|
+
# Confidential / decryptWith values ship as the `*****<hash>` redacted
|
|
1224
|
+
# form, never the plaintext (qfg-2agi.15).
|
|
1225
|
+
selected_value: result.reportable_value.nil? ? result.unwrapped_value : result.reportable_value,
|
|
1157
1226
|
reason: result.wire_reason
|
|
1158
1227
|
)
|
|
1159
1228
|
rescue StandardError => e
|
|
@@ -1516,7 +1585,7 @@ module Quonfig
|
|
|
1516
1585
|
|
|
1517
1586
|
# Combine the customer-supplied globalContext with the optional dev
|
|
1518
1587
|
# context loaded from ~/.quonfig/tokens.json. Dev context goes UNDER the
|
|
1519
|
-
# customer's so
|
|
1588
|
+
# customer's, so an explicit customer `quonfig-user` context replaces it.
|
|
1520
1589
|
def build_initial_global_context(options)
|
|
1521
1590
|
customer = normalize_context(options.global_context)
|
|
1522
1591
|
return customer unless dev_context_enabled?(options)
|
|
@@ -1549,21 +1618,19 @@ module Quonfig
|
|
|
1549
1618
|
raise ArgumentError, "Quonfig context must be a Hash, got #{ctx.class}"
|
|
1550
1619
|
end
|
|
1551
1620
|
|
|
1552
|
-
#
|
|
1553
|
-
#
|
|
1554
|
-
#
|
|
1621
|
+
# Combine two context tiers (qfg-2agi.24): a named context in +right+
|
|
1622
|
+
# REPLACES the whole same-named context in +left+ (no per-property merge);
|
|
1623
|
+
# named contexts only in +left+ survive. Names compare as strings, so
|
|
1624
|
+
# +:user+ and +'user'+ are the same named context.
|
|
1555
1625
|
def merge_contexts(left, right)
|
|
1556
1626
|
return right || {} if left.nil? || left.empty?
|
|
1557
1627
|
return left if right.nil? || right.empty?
|
|
1558
1628
|
|
|
1559
1629
|
merged = {}
|
|
1560
|
-
left.each
|
|
1630
|
+
left.each { |name, ctx| merged[name] = ctx.is_a?(Hash) ? ctx.dup : ctx }
|
|
1561
1631
|
right.each do |name, ctx|
|
|
1562
|
-
merged
|
|
1563
|
-
|
|
1564
|
-
else
|
|
1565
|
-
ctx.is_a?(Hash) ? ctx.dup : ctx
|
|
1566
|
-
end
|
|
1632
|
+
merged.delete_if { |existing, _| existing.to_s == name.to_s }
|
|
1633
|
+
merged[name] = ctx.is_a?(Hash) ? ctx.dup : ctx
|
|
1567
1634
|
end
|
|
1568
1635
|
merged
|
|
1569
1636
|
end
|
|
@@ -1623,6 +1690,10 @@ module Quonfig
|
|
|
1623
1690
|
raw_value = result.unwrapped_value
|
|
1624
1691
|
|
|
1625
1692
|
begin
|
|
1693
|
+
if expected_type == :duration
|
|
1694
|
+
raise Quonfig::Errors::TypeMismatchError.new(key, 'ISO-8601 Duration', raw_value) unless duration_result?(result)
|
|
1695
|
+
raise malformed_stored_duration_error(result) if malformed_stored_duration?(result, raw_value)
|
|
1696
|
+
end
|
|
1626
1697
|
coerced = coerce_and_check(key, raw_value, expected_type) unless raw_value.nil?
|
|
1627
1698
|
rescue Quonfig::Errors::TypeMismatchError => e
|
|
1628
1699
|
return Quonfig::EvaluationDetails.new(
|
|
@@ -1701,6 +1772,42 @@ module Quonfig
|
|
|
1701
1772
|
coerce_and_check(key, value, expected_type)
|
|
1702
1773
|
end
|
|
1703
1774
|
|
|
1775
|
+
def duration_millis(key, result, default)
|
|
1776
|
+
raise Quonfig::Errors::TypeMismatchError.new(key, 'ISO-8601 Duration', result.unwrapped_value) unless duration_result?(result)
|
|
1777
|
+
|
|
1778
|
+
value = result.unwrapped_value
|
|
1779
|
+
millis = value.is_a?(Integer) ? value : Quonfig::Duration.parse_millis(value)
|
|
1780
|
+
return millis unless millis.nil?
|
|
1781
|
+
|
|
1782
|
+
malformed_duration(key, default, malformed_stored_duration_error(result))
|
|
1783
|
+
end
|
|
1784
|
+
|
|
1785
|
+
def duration_result?(result)
|
|
1786
|
+
config = result.config || {}
|
|
1787
|
+
value_type = config['valueType'] || config[:valueType] || config['value_type'] || config[:value_type]
|
|
1788
|
+
value_type == 'duration' || result.type == 'duration'
|
|
1789
|
+
end
|
|
1790
|
+
|
|
1791
|
+
# A stored duration string outside the shared grammar unwraps to nil.
|
|
1792
|
+
def malformed_stored_duration?(result, unwrapped)
|
|
1793
|
+
unwrapped.nil? && result.type == 'duration' && !result.raw_value.nil?
|
|
1794
|
+
end
|
|
1795
|
+
|
|
1796
|
+
def malformed_stored_duration_error(result)
|
|
1797
|
+
Quonfig::Errors::EnvVarParseError.new(result.raw_value, result.config || {}, 'stored value')
|
|
1798
|
+
end
|
|
1799
|
+
|
|
1800
|
+
def malformed_duration(key, default, error)
|
|
1801
|
+
if @malformed_duration_mutex.synchronize { @malformed_duration_warned.add?(key.to_s) }
|
|
1802
|
+
LOG.warn "[quonfig] #{key} is not a valid ISO-8601 duration; " \
|
|
1803
|
+
"#{default == NO_DEFAULT_PROVIDED ? 'no default given' : 'returning the default'}"
|
|
1804
|
+
end
|
|
1805
|
+
return default unless default == NO_DEFAULT_PROVIDED
|
|
1806
|
+
raise error if @options.on_no_default == Quonfig::Options::ON_NO_DEFAULT::RAISE
|
|
1807
|
+
|
|
1808
|
+
nil
|
|
1809
|
+
end
|
|
1810
|
+
|
|
1704
1811
|
def coerce_and_check(key, value, expected_type)
|
|
1705
1812
|
case expected_type
|
|
1706
1813
|
when :bool
|
|
@@ -1713,10 +1820,12 @@ module Quonfig
|
|
|
1713
1820
|
|
|
1714
1821
|
arr
|
|
1715
1822
|
when :duration
|
|
1716
|
-
return value
|
|
1717
|
-
|
|
1823
|
+
return value if value.is_a?(Integer)
|
|
1824
|
+
|
|
1825
|
+
millis = Quonfig::Duration.parse_millis(value)
|
|
1826
|
+
raise Quonfig::Errors::TypeMismatchError.new(key, 'ISO-8601 Duration', value) if millis.nil?
|
|
1718
1827
|
|
|
1719
|
-
|
|
1828
|
+
millis
|
|
1720
1829
|
when :json
|
|
1721
1830
|
# JSON values are returned as-is (Hash, Array, or scalar from the wire).
|
|
1722
1831
|
value
|
data/lib/quonfig/duration.rb
CHANGED
|
@@ -1,27 +1,76 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Quonfig
|
|
4
|
+
# ISO-8601 duration parsing for the grammar every Quonfig SDK shares
|
|
5
|
+
# (integration-test-data/tests/duration/grammar.yaml, qfg-2agi.29):
|
|
6
|
+
#
|
|
7
|
+
# \AP(?:[0-9]+D)?(?:T(?:[0-9]+H)?(?:[0-9]+M)?(?:[0-9]+(?:\.[0-9]{1,9})?S)?)?\z
|
|
8
|
+
#
|
|
9
|
+
# plus: at least one component, no dangling T, a fraction only on S (at
|
|
10
|
+
# most 9 digits), total magnitude <= P36500D. Anchored with \A/\z (Ruby's
|
|
11
|
+
# ^/$ match at line boundaries) and [0-9] (never \d).
|
|
12
|
+
#
|
|
13
|
+
# Milliseconds use exact Rational arithmetic, rounded half up.
|
|
4
14
|
class Duration
|
|
5
|
-
PATTERN =
|
|
15
|
+
PATTERN = /\AP(?:(?<days>[0-9]+)D)?(?:T(?<time>(?:(?<hours>[0-9]+)H)?(?:(?<minutes>[0-9]+)M)?(?:(?<seconds>[0-9]+(?:\.[0-9]{1,9})?)S)?))?\z/
|
|
6
16
|
MINUTES_IN_SECONDS = 60
|
|
7
17
|
HOURS_IN_SECONDS = 60 * MINUTES_IN_SECONDS
|
|
8
18
|
DAYS_IN_SECONDS = 24 * HOURS_IN_SECONDS
|
|
19
|
+
MAX_SECONDS = 36_500 * DAYS_IN_SECONDS
|
|
9
20
|
|
|
10
21
|
def initialize(definition)
|
|
11
22
|
@seconds = self.class.parse(definition)
|
|
12
23
|
end
|
|
13
24
|
|
|
25
|
+
# Integer milliseconds for a valid duration string, or nil when +definition+
|
|
26
|
+
# is not a String or does not match the grammar.
|
|
27
|
+
def self.parse_millis(definition)
|
|
28
|
+
seconds = exact_seconds(definition)
|
|
29
|
+
return nil if seconds.nil?
|
|
30
|
+
|
|
31
|
+
(seconds * 1000).round(half: :up).to_i
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.valid?(definition)
|
|
35
|
+
!exact_seconds(definition).nil?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Seconds as a Float. Raises ArgumentError when +definition+ is not a valid
|
|
39
|
+
# duration (it used to return 0 for anything unparseable).
|
|
14
40
|
def self.parse(definition)
|
|
41
|
+
seconds = exact_seconds(definition)
|
|
42
|
+
raise ArgumentError, "invalid ISO-8601 duration: #{definition.inspect}" if seconds.nil?
|
|
43
|
+
|
|
44
|
+
seconds.to_f
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Exact Rational seconds, or nil when invalid.
|
|
48
|
+
def self.exact_seconds(definition)
|
|
49
|
+
return nil unless definition.is_a?(String)
|
|
50
|
+
|
|
15
51
|
match = PATTERN.match(definition)
|
|
16
|
-
return
|
|
52
|
+
return nil if match.nil?
|
|
53
|
+
return nil if match[:days].nil? && match[:time].nil?
|
|
54
|
+
return nil if match[:time] && match[:time].empty?
|
|
55
|
+
|
|
56
|
+
total = (Integer(match[:days] || '0', 10) * DAYS_IN_SECONDS) +
|
|
57
|
+
(Integer(match[:hours] || '0', 10) * HOURS_IN_SECONDS) +
|
|
58
|
+
(Integer(match[:minutes] || '0', 10) * MINUTES_IN_SECONDS) +
|
|
59
|
+
decimal_seconds(match[:seconds])
|
|
60
|
+
return nil if total > MAX_SECONDS
|
|
61
|
+
|
|
62
|
+
total
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.decimal_seconds(text)
|
|
66
|
+
return 0 if text.nil?
|
|
17
67
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
minutes = match[:minutes]&.to_f || 0
|
|
21
|
-
seconds = match[:seconds]&.to_f || 0
|
|
68
|
+
whole, frac = text.split('.', 2)
|
|
69
|
+
return Integer(whole, 10) if frac.nil?
|
|
22
70
|
|
|
23
|
-
(
|
|
71
|
+
Integer(whole, 10) + Rational(Integer(frac, 10), 10**frac.length)
|
|
24
72
|
end
|
|
73
|
+
private_class_method :decimal_seconds
|
|
25
74
|
|
|
26
75
|
def in_seconds
|
|
27
76
|
@seconds
|
data/lib/quonfig/evaluator.rb
CHANGED
|
@@ -556,8 +556,9 @@ module Quonfig
|
|
|
556
556
|
when Numeric
|
|
557
557
|
raw.to_i
|
|
558
558
|
when String
|
|
559
|
-
|
|
560
|
-
|
|
559
|
+
# nil for a malformed value, so get() never returns the raw string or
|
|
560
|
+
# a made-up number; Client#get_duration applies the default.
|
|
561
|
+
Quonfig::Duration.parse_millis(raw)
|
|
561
562
|
when Hash
|
|
562
563
|
secs = (raw['seconds'] || raw[:seconds] || 0).to_f
|
|
563
564
|
nanos = (raw['nanos'] || raw[:nanos] || 0).to_f
|
data/lib/quonfig/murmer3.rb
CHANGED
|
@@ -25,10 +25,14 @@ class Murmur3
|
|
|
25
25
|
(k1 * 0x1b873593) & MASK32
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
+
# Hashes the string's BYTES (UTF-8 for any normal Ruby string), like every
|
|
29
|
+
# other Quonfig SDK. Using the character count instead of the byte count put
|
|
30
|
+
# non-ASCII input in the wrong tail/length mix (qfg-1mvb).
|
|
28
31
|
def self.murmur3_32(str, seed = 0)
|
|
32
|
+
bytes = str.b
|
|
29
33
|
h1 = seed
|
|
30
|
-
numbers =
|
|
31
|
-
tailn =
|
|
34
|
+
numbers = bytes.unpack('V*C*')
|
|
35
|
+
tailn = bytes.bytesize % 4
|
|
32
36
|
tail = numbers.slice!(numbers.size - tailn, tailn)
|
|
33
37
|
numbers.each do |k1|
|
|
34
38
|
h1 ^= murmur3_32__mmix(k1)
|
|
@@ -44,7 +48,7 @@ class Murmur3
|
|
|
44
48
|
h1 ^= murmur3_32__mmix(k1)
|
|
45
49
|
end
|
|
46
50
|
|
|
47
|
-
h1 ^=
|
|
51
|
+
h1 ^= bytes.bytesize
|
|
48
52
|
murmur3_32_fmix(h1)
|
|
49
53
|
end
|
|
50
54
|
end
|
data/lib/quonfig/reason.rb
CHANGED
|
@@ -7,7 +7,7 @@ module Quonfig
|
|
|
7
7
|
# :DEFAULT — config has no targeting rules; matched value is the static default
|
|
8
8
|
# :RULE_MATCH — at least one targeting rule exists on the config (the matched
|
|
9
9
|
# conditional may itself be ALWAYS_TRUE, but the *config* is targeted)
|
|
10
|
-
# :SPLIT — matched value came from a
|
|
10
|
+
# :SPLIT — matched value came from a weighted variant (any bucket, including 0)
|
|
11
11
|
# :ERROR — evaluation failed
|
|
12
12
|
# :UNKNOWN — unable to determine
|
|
13
13
|
module Reason
|
|
@@ -20,7 +20,7 @@ module Quonfig
|
|
|
20
20
|
module_function
|
|
21
21
|
|
|
22
22
|
def compute(config:, conditional_value:, weighted_value_index: nil)
|
|
23
|
-
return SPLIT
|
|
23
|
+
return SPLIT unless weighted_value_index.nil?
|
|
24
24
|
return RULE_MATCH if targeting_rules?(config)
|
|
25
25
|
return RULE_MATCH if non_always_true_criteria?(conditional_value)
|
|
26
26
|
|
data/lib/quonfig/resolver.rb
CHANGED
|
@@ -274,6 +274,8 @@ module Quonfig
|
|
|
274
274
|
when 'string_list'
|
|
275
275
|
env_value.split(/\s*,\s*/)
|
|
276
276
|
when 'duration'
|
|
277
|
+
raise ArgumentError, 'invalid ISO-8601 duration' unless Quonfig::Duration.valid?(env_value)
|
|
278
|
+
|
|
277
279
|
env_value
|
|
278
280
|
else
|
|
279
281
|
env_value
|
|
@@ -288,6 +290,9 @@ module Quonfig
|
|
|
288
290
|
when 'double' then 'double'
|
|
289
291
|
when 'bool' then 'bool'
|
|
290
292
|
when 'string_list' then 'string_list'
|
|
293
|
+
# Keep 'duration' so Evaluator#unwrapped_value converts to ms and
|
|
294
|
+
# get() matches the stored path (qfg-2agi.22).
|
|
295
|
+
when 'duration' then 'duration'
|
|
291
296
|
else 'string'
|
|
292
297
|
end
|
|
293
298
|
end
|
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.
|
|
4
|
+
version: 1.7.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-
|
|
11
|
+
date: 2026-10-03 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|