quonfig 1.7.2 → 1.8.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 +26 -0
- data/README.md +24 -0
- data/lib/quonfig/client.rb +146 -42
- data/lib/quonfig/config_loader.rb +129 -26
- data/lib/quonfig/errors/env_var_parse_error.rb +10 -3
- data/lib/quonfig/evaluator.rb +72 -14
- data/lib/quonfig/resolver.rb +22 -7
- data/lib/quonfig/semver.rb +11 -1
- data/lib/quonfig/sse_config_client.rb +101 -54
- data/lib/quonfig/stdlib_formatter.rb +12 -1
- data/lib/quonfig/telemetry/example_contexts_aggregator.rb +55 -1
- data/lib/quonfig/version.rb +1 -1
- data/lib/quonfig/waits.rb +30 -0
- data/lib/quonfig/worker_supervisor.rb +5 -0
- data/lib/quonfig.rb +1 -0
- metadata +3 -2
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
module Quonfig
|
|
4
4
|
module Errors
|
|
5
5
|
class EnvVarParseError < Quonfig::Error
|
|
6
|
-
|
|
6
|
+
# +stored: true+ marks a malformed stored value (no env var involved);
|
|
7
|
+
# the message then names the stored value rather than a variable.
|
|
8
|
+
def initialize(_env_var, config, env_var_name, stored: false)
|
|
7
9
|
key, value_type =
|
|
8
10
|
if config.is_a?(Hash)
|
|
9
11
|
[config[:key] || config['key'],
|
|
@@ -12,8 +14,13 @@ module Quonfig
|
|
|
12
14
|
[config.key, config.value_type]
|
|
13
15
|
end
|
|
14
16
|
# +_env_var+ (the raw value) is deliberately left out of the message:
|
|
15
|
-
# an env var or stored value can be a secret (qfg-goi1.2.11).
|
|
16
|
-
|
|
17
|
+
# an env var or stored value can be a secret (qfg-goi1.2.11). The
|
|
18
|
+
# wording is the cross-SDK template pinned by integration-test-data
|
|
19
|
+
# (v2026.10.12): it names the variable, the lowercase type and the
|
|
20
|
+
# config key, never the value or a parser message quoting it.
|
|
21
|
+
type_name = value_type.to_s.downcase
|
|
22
|
+
source = stored ? 'stored value' : %(environment variable "#{env_var_name}")
|
|
23
|
+
super(%(#{source} cannot be converted to #{type_name} for config "#{key}"))
|
|
17
24
|
end
|
|
18
25
|
end
|
|
19
26
|
end
|
data/lib/quonfig/evaluator.rb
CHANGED
|
@@ -54,6 +54,16 @@ module Quonfig
|
|
|
54
54
|
OP_IS_PRESENT = 'IS_PRESENT'
|
|
55
55
|
OP_IS_NOT_PRESENT = 'IS_NOT_PRESENT'
|
|
56
56
|
|
|
57
|
+
MAX_SAFE_INTEGER = (2**53) - 1
|
|
58
|
+
|
|
59
|
+
# Timestamp grammar for PROP_BEFORE / PROP_AFTER; see #date_to_millis.
|
|
60
|
+
INTEGER_STRING = /\A-?[0-9]+\z/
|
|
61
|
+
RFC3339_TIMESTAMP = /
|
|
62
|
+
\A(?<y>[0-9]{4})-(?<mo>[0-9]{2})-(?<d>[0-9]{2})
|
|
63
|
+
T(?<h>[0-9]{2}):(?<mi>[0-9]{2}):(?<s>[0-9]{2})(?:\.(?<frac>[0-9]+))?
|
|
64
|
+
(?:Z|(?<sign>[+-])(?<oh>[0-9]{2}):(?<om>[0-9]{2}))\z
|
|
65
|
+
/x
|
|
66
|
+
|
|
57
67
|
MAGIC_CURRENT_TIME_PROPS = %w[quonfig.current-time prefab.current-time reforge.current-time].freeze
|
|
58
68
|
|
|
59
69
|
# PROP_MATCHES / PROP_DOES_NOT_MATCH match timeout (qfg-goi1.2.11). Ruby
|
|
@@ -282,8 +292,8 @@ module Quonfig
|
|
|
282
292
|
when OP_PROP_SEMVER_LESS_THAN, OP_PROP_SEMVER_EQUAL, OP_PROP_SEMVER_GREATER_THAN
|
|
283
293
|
mv = hget(match_value, :value)
|
|
284
294
|
if context_exists && context_value.is_a?(String) && mv.is_a?(String)
|
|
285
|
-
sv_ctx = SemanticVersion.parse_quietly(context_value)
|
|
286
|
-
sv_mv = SemanticVersion.parse_quietly(mv)
|
|
295
|
+
sv_ctx = SemanticVersion.parse_quietly(context_value, allow_abbreviated: true)
|
|
296
|
+
sv_mv = SemanticVersion.parse_quietly(mv, allow_abbreviated: true)
|
|
287
297
|
if sv_ctx && sv_mv
|
|
288
298
|
cmp = (sv_ctx <=> sv_mv)
|
|
289
299
|
case operator
|
|
@@ -356,12 +366,27 @@ module Quonfig
|
|
|
356
366
|
|
|
357
367
|
# --- Type coercion helpers ----------------------------------------
|
|
358
368
|
|
|
369
|
+
# Canonical string form (qfg-goi1.5 R1) for *_ONE_OF, the string
|
|
370
|
+
# operators and HIERARCHICAL_MATCH: strings as-is, booleans as
|
|
371
|
+
# "true"/"false", and an integral number below 2^53 as plain digits
|
|
372
|
+
# whatever its runtime type, so 1.0 is "1" and 5e9 is "5000000000"
|
|
373
|
+
# (Float#to_s would give "1.0" and "5.0e9").
|
|
359
374
|
def to_s_nil(v)
|
|
360
375
|
return '' if v.nil?
|
|
376
|
+
return v if v.is_a?(String)
|
|
377
|
+
return canonical_number(v) if v.is_a?(Numeric) && !v.is_a?(Integer)
|
|
361
378
|
|
|
362
379
|
v.to_s
|
|
363
380
|
end
|
|
364
381
|
|
|
382
|
+
def canonical_number(v)
|
|
383
|
+
return v.to_s unless v.real?
|
|
384
|
+
return v.to_s if v.respond_to?(:finite?) && !v.finite?
|
|
385
|
+
return v.to_s unless v == v.truncate && v.abs <= MAX_SAFE_INTEGER
|
|
386
|
+
|
|
387
|
+
v.to_i.to_s
|
|
388
|
+
end
|
|
389
|
+
|
|
365
390
|
def to_string_slice(v)
|
|
366
391
|
return [] if v.nil?
|
|
367
392
|
return v.map { |i| to_s_nil(i) } if v.is_a?(Array)
|
|
@@ -414,21 +439,43 @@ module Quonfig
|
|
|
414
439
|
[start_v || min, end_v || max]
|
|
415
440
|
end
|
|
416
441
|
|
|
442
|
+
# The ONE timestamp grammar for PROP_BEFORE / PROP_AFTER (qfg-goi1.5 R5),
|
|
443
|
+
# applied to both the context value and the criterion: a number (epoch
|
|
444
|
+
# ms, truncated), an all-digit string (epoch ms), or RFC3339 with an
|
|
445
|
+
# uppercase T and a Z or numeric offset (fractional seconds allowed,
|
|
446
|
+
# truncated to ms). Date-only, offset-less and free-form strings are not
|
|
447
|
+
# timestamps and never match. This replaces DateTime.parse, which read
|
|
448
|
+
# any of those (offset-less as UTC).
|
|
417
449
|
def date_to_millis(val)
|
|
418
450
|
case val
|
|
419
|
-
when Integer
|
|
420
|
-
val
|
|
451
|
+
when Integer
|
|
452
|
+
val
|
|
453
|
+
when Float
|
|
454
|
+
val.finite? ? val.to_i : nil
|
|
421
455
|
when String
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
456
|
+
return Integer(val, 10) if INTEGER_STRING.match?(val)
|
|
457
|
+
|
|
458
|
+
rfc3339_to_millis(val)
|
|
459
|
+
end
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
def rfc3339_to_millis(str)
|
|
463
|
+
m = RFC3339_TIMESTAMP.match(str)
|
|
464
|
+
return nil if m.nil?
|
|
465
|
+
|
|
466
|
+
y, mo, d, h, mi, s = %i[y mo d h mi s].map { |k| m[k].to_i }
|
|
467
|
+
return nil unless Date.valid_date?(y, mo, d) && h <= 23 && mi <= 59 && s <= 59
|
|
468
|
+
|
|
469
|
+
offset_seconds = 0
|
|
470
|
+
if m[:sign]
|
|
471
|
+
oh = m[:oh].to_i
|
|
472
|
+
om = m[:om].to_i
|
|
473
|
+
return nil if oh > 23 || om > 59
|
|
430
474
|
|
|
475
|
+
offset_seconds = ((oh * 3600) + (om * 60)) * (m[:sign] == '-' ? -1 : 1)
|
|
431
476
|
end
|
|
477
|
+
epoch_seconds = Time.utc(y, mo, d, h, mi, s).to_i - offset_seconds
|
|
478
|
+
(epoch_seconds * 1000) + (m[:frac] || '').ljust(3, '0')[0, 3].to_i
|
|
432
479
|
end
|
|
433
480
|
end
|
|
434
481
|
|
|
@@ -540,8 +587,11 @@ module Quonfig
|
|
|
540
587
|
when 'log_level' then raw.is_a?(Numeric) ? raw : raw.to_s
|
|
541
588
|
when 'duration' then duration_to_millis(raw)
|
|
542
589
|
when 'json'
|
|
543
|
-
# JSON values must be native JS/Ruby types on the wire.
|
|
544
|
-
|
|
590
|
+
# JSON values must be native JS/Ruby types on the wire. Hashes and
|
|
591
|
+
# Arrays are copied so a caller mutating the result cannot rewrite
|
|
592
|
+
# the cached config for every later read (qfg-goi1.10.3, sdk-go
|
|
593
|
+
# qfg-9dxb.6). Leaf values (strings included) are not copied.
|
|
594
|
+
copy_json_containers(raw)
|
|
545
595
|
else
|
|
546
596
|
raw
|
|
547
597
|
end
|
|
@@ -555,6 +605,14 @@ module Quonfig
|
|
|
555
605
|
|
|
556
606
|
private
|
|
557
607
|
|
|
608
|
+
def copy_json_containers(value)
|
|
609
|
+
case value
|
|
610
|
+
when Hash then value.to_h { |k, v| [k, copy_json_containers(v)] }
|
|
611
|
+
when Array then value.map { |v| copy_json_containers(v) }
|
|
612
|
+
else value
|
|
613
|
+
end
|
|
614
|
+
end
|
|
615
|
+
|
|
558
616
|
# Coerce an ISO 8601 duration value (e.g. "PT0.2S", "P1DT6H2M1.5S")
|
|
559
617
|
# to integer milliseconds. The wire format may either be a bare ISO
|
|
560
618
|
# string in `value` or the structured `{ seconds, nanos }` proto-style
|
data/lib/quonfig/resolver.rb
CHANGED
|
@@ -56,15 +56,21 @@ module Quonfig
|
|
|
56
56
|
|
|
57
57
|
weighted_index = nil
|
|
58
58
|
hash_missing = []
|
|
59
|
-
|
|
59
|
+
selected_variants = []
|
|
60
|
+
resolved_value = resolve_value_on_path(eval_result.value, config, context, [], hash_missing,
|
|
61
|
+
selected_variants) do |idx|
|
|
60
62
|
weighted_index = idx
|
|
61
63
|
end
|
|
64
|
+
# Redact from the selected rollout variant, not only the outer
|
|
65
|
+
# weighted_values wrapper (which is never flagged), so a confidential or
|
|
66
|
+
# decryptWith variant never reaches telemetry in plaintext (qfg-goi1.2.39).
|
|
67
|
+
reportable = redacted_reportable_value(eval_result.value) || redacted_reportable_value(selected_variants.last)
|
|
62
68
|
EvalResult.new(
|
|
63
69
|
value: resolved_value,
|
|
64
70
|
rule_index: eval_result.rule_index,
|
|
65
71
|
config: config,
|
|
66
72
|
weighted_value_index: weighted_index,
|
|
67
|
-
reportable_value:
|
|
73
|
+
reportable_value: reportable,
|
|
68
74
|
hash_property_missing: !hash_missing.empty?
|
|
69
75
|
)
|
|
70
76
|
end
|
|
@@ -91,15 +97,21 @@ module Quonfig
|
|
|
91
97
|
# DecryptionError instead of recursing until SystemStackError
|
|
92
98
|
# (qfg-9dxb.7, matching sdk-go qfg-9dxb.4). +hash_missing+, when given,
|
|
93
99
|
# gets an entry for each weighted rollout whose hash property was missing
|
|
94
|
-
# from the context (qfg-9dxb.8).
|
|
95
|
-
|
|
100
|
+
# from the context (qfg-9dxb.8). +selected_variants+, when given, gets the
|
|
101
|
+
# stored (pre-resolution) Value of each weighted variant picked, so get()
|
|
102
|
+
# can redact a confidential/decryptWith variant (qfg-goi1.2.39).
|
|
103
|
+
def resolve_value_on_path(value, config, context, key_path, hash_missing = nil, selected_variants = nil,
|
|
104
|
+
&on_weighted_index)
|
|
96
105
|
return nil if value.nil?
|
|
97
106
|
|
|
98
107
|
type = vget(value, :type, 'type')
|
|
99
108
|
|
|
100
109
|
return resolve_provided(value, config) if type == 'provided'
|
|
101
110
|
|
|
102
|
-
|
|
111
|
+
if type == 'weighted_values'
|
|
112
|
+
return resolve_weighted(value, config, context, key_path, hash_missing, selected_variants,
|
|
113
|
+
&on_weighted_index)
|
|
114
|
+
end
|
|
103
115
|
|
|
104
116
|
confidential = vget(value, :confidential, 'confidential')
|
|
105
117
|
decrypt_with = vget(value, :decryptWith, 'decryptWith', :decrypt_with, 'decrypt_with')
|
|
@@ -184,7 +196,8 @@ module Quonfig
|
|
|
184
196
|
# nil) hashes an empty value, the same bucket as a present "", and warns
|
|
185
197
|
# once per config key (qfg-9dxb.8). With no hash property configured the
|
|
186
198
|
# pick stays random per call.
|
|
187
|
-
def resolve_weighted(value, config, context, key_path, hash_missing = nil,
|
|
199
|
+
def resolve_weighted(value, config, context, key_path, hash_missing = nil, selected_variants = nil,
|
|
200
|
+
&on_weighted_index)
|
|
188
201
|
payload = vget(value, :value, 'value') || {}
|
|
189
202
|
weighted = vget(payload, :weightedValues, 'weightedValues', :weighted_values, 'weighted_values')
|
|
190
203
|
return value unless weighted.is_a?(Array) && !weighted.empty?
|
|
@@ -213,7 +226,9 @@ module Quonfig
|
|
|
213
226
|
variant, index = picker.resolve
|
|
214
227
|
on_weighted_index&.call(index)
|
|
215
228
|
variant_value = vget(variant, :value, 'value')
|
|
216
|
-
|
|
229
|
+
selected_variants&.push(variant_value)
|
|
230
|
+
resolve_value_on_path(variant_value, config, context, key_path, hash_missing, selected_variants,
|
|
231
|
+
&on_weighted_index)
|
|
217
232
|
end
|
|
218
233
|
|
|
219
234
|
def warn_hash_property_missing(cfg_key, hash_property)
|
data/lib/quonfig/semver.rb
CHANGED
|
@@ -18,6 +18,11 @@ class SemanticVersion
|
|
|
18
18
|
$
|
|
19
19
|
/x
|
|
20
20
|
|
|
21
|
+
# Abbreviated "x" and "x.y" (qfg-goi1.5 Q5), read as x.0.0 and x.y.0 by
|
|
22
|
+
# the PROP_SEMVER_* operators, matching LaunchDarkly. A prerelease or build
|
|
23
|
+
# suffix needs the full x.y.z.
|
|
24
|
+
ABBREVIATED_PATTERN = /\A(?<major>0|[1-9]\d*)(?:\.(?<minor>0|[1-9]\d*))?\z/
|
|
25
|
+
|
|
21
26
|
attr_reader :major, :minor, :patch, :prerelease, :build_metadata
|
|
22
27
|
|
|
23
28
|
def self.parse(version_string)
|
|
@@ -36,7 +41,12 @@ class SemanticVersion
|
|
|
36
41
|
)
|
|
37
42
|
end
|
|
38
43
|
|
|
39
|
-
def self.parse_quietly(version_string)
|
|
44
|
+
def self.parse_quietly(version_string, allow_abbreviated: false)
|
|
45
|
+
if allow_abbreviated && version_string.is_a?(String)
|
|
46
|
+
match = ABBREVIATED_PATTERN.match(version_string)
|
|
47
|
+
return new(major: match[:major].to_i, minor: match[:minor].to_i, patch: 0) if match
|
|
48
|
+
end
|
|
49
|
+
|
|
40
50
|
parse(version_string)
|
|
41
51
|
rescue ArgumentError
|
|
42
52
|
nil
|
|
@@ -51,10 +51,17 @@ module Quonfig
|
|
|
51
51
|
|
|
52
52
|
LOG = Quonfig::InternalLogger.new(self)
|
|
53
53
|
|
|
54
|
-
#
|
|
55
|
-
#
|
|
56
|
-
#
|
|
57
|
-
|
|
54
|
+
# Auth-class statuses (rule R1, qfg-goi1.10.26). On the stream they keep the
|
|
55
|
+
# normal reconnect ladder: SSE alone never stops retrying and never enters
|
|
56
|
+
# auth-failed (R2); the client's fallback poller decides that from a whole
|
|
57
|
+
# fetch cycle. They only get their own log line: WARN once per status,
|
|
58
|
+
# re-armed by a 200, DEBUG otherwise (R5).
|
|
59
|
+
AUTH_HTTP_CODES = [401, 403, 404, 410].freeze
|
|
60
|
+
|
|
61
|
+
# @deprecated since 1.8.0. qfg-i5xv's "terminal" codes. Nothing is terminal
|
|
62
|
+
# any more (the latch is gone, qfg-goi1.10.26); kept as an alias of
|
|
63
|
+
# AUTH_HTTP_CODES for code that references the constant.
|
|
64
|
+
TERMINAL_HTTP_CODES = AUTH_HTTP_CODES
|
|
58
65
|
|
|
59
66
|
# qfg-6y44: headroom added to +sse_read_timeout+ when configuring
|
|
60
67
|
# Net::HTTP#read_timeout. The ReadDeadlineWatchdog already covers both the
|
|
@@ -88,6 +95,8 @@ module Quonfig
|
|
|
88
95
|
@active_http = nil
|
|
89
96
|
|
|
90
97
|
@last_event_id = nil
|
|
98
|
+
# Auth-class statuses already logged at WARN since the last 200 (R5).
|
|
99
|
+
@auth_warned = Set.new
|
|
91
100
|
# qfg-7h5d.1.9 / qfg-41nh.6: latches true if a connection attempt ever
|
|
92
101
|
# targets a non-primary sse_api_urls leg. The failover epic asserts SSE
|
|
93
102
|
# does NOT fail over to the secondary (f05) — the stream is PINNED to
|
|
@@ -220,7 +229,7 @@ module Quonfig
|
|
|
220
229
|
begin
|
|
221
230
|
unless first_attempt
|
|
222
231
|
increment_restart!
|
|
223
|
-
interruptible_sleep(jittered(delay))
|
|
232
|
+
interruptible_sleep(jittered(Quonfig::Waits.duration(:sse, delay)))
|
|
224
233
|
break if @stopped.value
|
|
225
234
|
end
|
|
226
235
|
first_attempt = false
|
|
@@ -314,26 +323,7 @@ module Quonfig
|
|
|
314
323
|
|
|
315
324
|
begin
|
|
316
325
|
http.request(req) do |resp|
|
|
317
|
-
|
|
318
|
-
if TERMINAL_HTTP_CODES.include?(code)
|
|
319
|
-
# qfg-i5xv: 401/403/404 will not heal by retrying — bad key,
|
|
320
|
-
# revoked permission, or wrong endpoint. Mark stopped *before*
|
|
321
|
-
# invoking on_error so the loop's terminal-error branch is
|
|
322
|
-
# already locked in if the parent callback inspects state, and
|
|
323
|
-
# so the inner rescue's `handle_error(e) unless @stopped.value`
|
|
324
|
-
# guard suppresses a second on_error edge.
|
|
325
|
-
err = SSEHTTPTerminalError.new(code)
|
|
326
|
-
@logger.error "SSE Streaming Terminal Error: HTTP #{code} for url #{url}; will not retry"
|
|
327
|
-
@stopped.make_true
|
|
328
|
-
invoke_on_error(err)
|
|
329
|
-
raise err
|
|
330
|
-
end
|
|
331
|
-
if code != 200
|
|
332
|
-
err = SSEHTTPStatusError.new(code)
|
|
333
|
-
@logger.error "SSE Streaming Error: HTTP #{code} for url #{url}"
|
|
334
|
-
invoke_on_error(err)
|
|
335
|
-
raise err
|
|
336
|
-
end
|
|
326
|
+
check_response_status!(resp.code.to_i, url)
|
|
337
327
|
|
|
338
328
|
parser = EventParser.new
|
|
339
329
|
# qfg-tj18: run_loop wraps the body in +:on_blocking+ which
|
|
@@ -365,6 +355,35 @@ module Quonfig
|
|
|
365
355
|
end
|
|
366
356
|
end
|
|
367
357
|
|
|
358
|
+
# Classify the stream response. Raises for any non-200 so run_loop applies
|
|
359
|
+
# its backoff.
|
|
360
|
+
#
|
|
361
|
+
# Auth class (401/403/404/410, qfg-goi1.10.26): no latch. The normal
|
|
362
|
+
# ladder applies and run_loop's handle_error fires on_error once, so the
|
|
363
|
+
# client schedules the fallback poller exactly as for any drop; that
|
|
364
|
+
# poller's fetch cycle is what decides auth-failed (R2). Logged WARN once
|
|
365
|
+
# per status until a 200 re-arms it, DEBUG otherwise (R5), so a revoked
|
|
366
|
+
# key does not log two ERRORs per attempt for the ~2 min before entry.
|
|
367
|
+
def check_response_status!(code, url)
|
|
368
|
+
if AUTH_HTTP_CODES.include?(code)
|
|
369
|
+
if @auth_warned.add?(code)
|
|
370
|
+
@logger.warn "SSE Streaming Error: HTTP #{code} for url #{url}; " \
|
|
371
|
+
'the SDK key may be invalid or revoked (check the SDK key); retrying'
|
|
372
|
+
else
|
|
373
|
+
@logger.debug "SSE Streaming Error: HTTP #{code} for url #{url}; retrying"
|
|
374
|
+
end
|
|
375
|
+
raise SSEHTTPAuthError, code
|
|
376
|
+
end
|
|
377
|
+
if code != 200
|
|
378
|
+
err = SSEHTTPStatusError.new(code)
|
|
379
|
+
@logger.error "SSE Streaming Error: HTTP #{code} for url #{url}"
|
|
380
|
+
invoke_on_error(err)
|
|
381
|
+
raise err
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
@auth_warned.clear
|
|
385
|
+
end
|
|
386
|
+
|
|
368
387
|
# Track the active connection so close() can interrupt a blocked
|
|
369
388
|
# read_body from another thread. Guarded by @conn_mutex.
|
|
370
389
|
def register_active(http)
|
|
@@ -376,7 +395,12 @@ module Quonfig
|
|
|
376
395
|
end
|
|
377
396
|
|
|
378
397
|
def handle_error(error)
|
|
379
|
-
|
|
398
|
+
if error.is_a?(SSEHTTPAuthError)
|
|
399
|
+
# Already logged once by check_response_status! (R5).
|
|
400
|
+
@logger.debug "SSE Streaming Error: #{error.inspect}"
|
|
401
|
+
else
|
|
402
|
+
@logger.error "SSE Streaming Error: #{error.inspect}"
|
|
403
|
+
end
|
|
380
404
|
invoke_on_error(error)
|
|
381
405
|
end
|
|
382
406
|
|
|
@@ -445,13 +469,14 @@ module Quonfig
|
|
|
445
469
|
end
|
|
446
470
|
end
|
|
447
471
|
|
|
448
|
-
#
|
|
449
|
-
#
|
|
450
|
-
#
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
#
|
|
454
|
-
|
|
472
|
+
# An auth-class (401/403/404/410) stream response (qfg-goi1.10.26). A
|
|
473
|
+
# subclass of SSEHTTPStatusError so on_error callbacks that only check
|
|
474
|
+
# `is_a?(SSEHTTPStatusError)` keep working. The stream keeps retrying.
|
|
475
|
+
class SSEHTTPAuthError < SSEHTTPStatusError; end
|
|
476
|
+
|
|
477
|
+
# @deprecated since 1.8.0: qfg-i5xv's name for SSEHTTPAuthError. The stream
|
|
478
|
+
# no longer stops on these statuses.
|
|
479
|
+
SSEHTTPTerminalError = SSEHTTPAuthError # rubocop:disable Lint/UselessConstantScoping
|
|
455
480
|
|
|
456
481
|
# Raised by the watchdog into the worker thread when the per-chunk
|
|
457
482
|
# read deadline elapses. Caught by run_loop's rescue, indistinguishable
|
|
@@ -630,37 +655,59 @@ module Quonfig
|
|
|
630
655
|
def initialize
|
|
631
656
|
@buffer = +''.b
|
|
632
657
|
@last_was_cr = false
|
|
658
|
+
# Bytes of @buffer already scanned without finding a terminator, so
|
|
659
|
+
# the next feed resumes there instead of rescanning from byte 0
|
|
660
|
+
# (qfg-goi1.10.2; O(n^2) on a long line in small chunks).
|
|
661
|
+
@scanned = 0
|
|
633
662
|
end
|
|
634
663
|
|
|
664
|
+
# Lines are sliced out by position and the consumed prefix is dropped
|
|
665
|
+
# once per feed (in +ensure+, so a raising block does not see a line
|
|
666
|
+
# twice), not once per line.
|
|
635
667
|
def feed(chunk)
|
|
636
668
|
@buffer << chunk.b
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
669
|
+
pos = 0
|
|
670
|
+
begin
|
|
671
|
+
loop do
|
|
672
|
+
idx = @buffer.index(/[\r\n]/, [pos, @scanned].max)
|
|
673
|
+
if idx.nil?
|
|
674
|
+
@scanned = @buffer.bytesize
|
|
675
|
+
break
|
|
676
|
+
end
|
|
640
677
|
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
678
|
+
ch = @buffer.getbyte(idx)
|
|
679
|
+
if idx == pos && ch == LF && @last_was_cr
|
|
680
|
+
# Dangling LF of a CRLF pair split across chunks — consume and skip.
|
|
681
|
+
@last_was_cr = false
|
|
682
|
+
pos += 1
|
|
683
|
+
next
|
|
684
|
+
end
|
|
648
685
|
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
686
|
+
line = @buffer.byteslice(pos, idx - pos).force_encoding('UTF-8')
|
|
687
|
+
consume_to = idx + 1
|
|
688
|
+
@last_was_cr = false
|
|
689
|
+
if ch == CR
|
|
690
|
+
if consume_to == @buffer.bytesize
|
|
691
|
+
# CR at end of buffer — could be CRLF split across feeds.
|
|
692
|
+
@last_was_cr = true
|
|
693
|
+
elsif @buffer.getbyte(consume_to) == LF
|
|
694
|
+
consume_to += 1
|
|
695
|
+
end
|
|
658
696
|
end
|
|
697
|
+
pos = consume_to
|
|
698
|
+
yield line
|
|
699
|
+
end
|
|
700
|
+
ensure
|
|
701
|
+
if pos.positive?
|
|
702
|
+
@buffer = @buffer.byteslice(pos, @buffer.bytesize - pos)
|
|
703
|
+
@scanned = [@scanned - pos, 0].max
|
|
659
704
|
end
|
|
660
|
-
@buffer.slice!(0, consume)
|
|
661
|
-
yield line
|
|
662
705
|
end
|
|
663
706
|
end
|
|
707
|
+
|
|
708
|
+
CR = 13
|
|
709
|
+
LF = 10
|
|
710
|
+
private_constant :CR, :LF
|
|
664
711
|
end
|
|
665
712
|
end
|
|
666
713
|
end
|
|
@@ -74,7 +74,7 @@ module Quonfig
|
|
|
74
74
|
path = logger_name || progname
|
|
75
75
|
level = SEVERITY_TO_LEVEL[severity.to_s.upcase] || :info
|
|
76
76
|
|
|
77
|
-
if
|
|
77
|
+
if emit?(client, path, level)
|
|
78
78
|
format_record(severity, datetime, progname, msg)
|
|
79
79
|
else
|
|
80
80
|
''
|
|
@@ -82,6 +82,17 @@ module Quonfig
|
|
|
82
82
|
end
|
|
83
83
|
end
|
|
84
84
|
|
|
85
|
+
# The formatter runs inside the host app's Logger calls and must never
|
|
86
|
+
# raise out of them. A resolution error (unset ENV_VAR, decryption
|
|
87
|
+
# failure, ...) emits the line, matching SemanticLoggerFilter
|
|
88
|
+
# (qfg-goi1.2.11, qfg-goi1.2.28). Nothing is logged here, since logging
|
|
89
|
+
# from inside a formatter can recurse.
|
|
90
|
+
def self.emit?(client, path, level)
|
|
91
|
+
client.should_log?(logger_path: path, desired_level: level)
|
|
92
|
+
rescue StandardError
|
|
93
|
+
true
|
|
94
|
+
end
|
|
95
|
+
|
|
85
96
|
# Default record formatter. Matches ::Logger::Formatter's general shape
|
|
86
97
|
# ("I, [timestamp pid] SEVERITY -- progname: message") but without the
|
|
87
98
|
# process-id first-letter noise so output is readable in tests and
|
|
@@ -15,6 +15,10 @@ module Quonfig
|
|
|
15
15
|
# prunes expired entries, and is not recorded if it is still full.
|
|
16
16
|
SEEN_CAP = 100_000
|
|
17
17
|
|
|
18
|
+
# Max nesting depth copied from a context value; deeper levels are left
|
|
19
|
+
# out. Guards the recursive copy against stack exhaustion.
|
|
20
|
+
MAX_SNAPSHOT_DEPTH = 32
|
|
21
|
+
|
|
18
22
|
attr_reader :data, :cache
|
|
19
23
|
|
|
20
24
|
def initialize(max_contexts:, rate_limit_seconds: ONE_HOUR_SECONDS, seen_cap: SEEN_CAP)
|
|
@@ -38,7 +42,7 @@ module Quonfig
|
|
|
38
42
|
return unless room_in_cache?
|
|
39
43
|
|
|
40
44
|
@cache.set(key)
|
|
41
|
-
@data.push([Quonfig::TimeHelpers.now_in_ms, context])
|
|
45
|
+
@data.push([Quonfig::TimeHelpers.now_in_ms, snapshot(context)])
|
|
42
46
|
end
|
|
43
47
|
|
|
44
48
|
def prepare_data
|
|
@@ -65,6 +69,19 @@ module Quonfig
|
|
|
65
69
|
|
|
66
70
|
private
|
|
67
71
|
|
|
72
|
+
# Copy +context+ into plain JSON-safe data at record time (qfg-goi1.2.35).
|
|
73
|
+
# The caller owns its nested Hashes/Arrays and may mutate them after
|
|
74
|
+
# get() returns, while the window is serialized later on the reporter
|
|
75
|
+
# thread. NaN/Infinity become nil, and an edge that closes a cycle is
|
|
76
|
+
# left out, so one bad value no longer makes JSON.generate raise and drop
|
|
77
|
+
# the whole window. Any other object is rendered with #to_s, which is
|
|
78
|
+
# what JSON.generate already sent for it (a UUID-like key keeps the
|
|
79
|
+
# example reportable); one whose #to_s raises is left out.
|
|
80
|
+
def snapshot(context)
|
|
81
|
+
named = contexts_to_list(context)
|
|
82
|
+
named.to_h { |entry| [entry['type'], snapshot_value(entry['values'], 0, {})] }
|
|
83
|
+
end
|
|
84
|
+
|
|
68
85
|
def room_in_cache?
|
|
69
86
|
return true if @cache.data.size < @seen_cap
|
|
70
87
|
|
|
@@ -102,6 +119,43 @@ module Quonfig
|
|
|
102
119
|
end
|
|
103
120
|
end
|
|
104
121
|
|
|
122
|
+
OMIT = Object.new.freeze
|
|
123
|
+
private_constant :OMIT
|
|
124
|
+
|
|
125
|
+
def snapshot_value(value, depth, ancestors)
|
|
126
|
+
case value
|
|
127
|
+
when nil, true, false, Integer then value
|
|
128
|
+
when Float then value.finite? ? value : nil
|
|
129
|
+
when String then value.frozen? ? value : value.dup
|
|
130
|
+
when Symbol then value.to_s
|
|
131
|
+
when Hash, Array then snapshot_container(value, depth, ancestors)
|
|
132
|
+
else value.to_s
|
|
133
|
+
end
|
|
134
|
+
rescue StandardError
|
|
135
|
+
OMIT
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# +ancestors+ holds the containers on the current path only, so the same
|
|
139
|
+
# value reached twice without a cycle is still copied both times.
|
|
140
|
+
def snapshot_container(value, depth, ancestors)
|
|
141
|
+
id = value.object_id
|
|
142
|
+
return OMIT if depth >= MAX_SNAPSHOT_DEPTH || ancestors.key?(id)
|
|
143
|
+
|
|
144
|
+
ancestors[id] = true
|
|
145
|
+
begin
|
|
146
|
+
if value.is_a?(Hash)
|
|
147
|
+
value.each_with_object({}) do |(k, v), acc|
|
|
148
|
+
copied = snapshot_value(v, depth + 1, ancestors)
|
|
149
|
+
acc[k.to_s] = copied unless copied.equal?(OMIT)
|
|
150
|
+
end
|
|
151
|
+
else
|
|
152
|
+
value.map { |v| snapshot_value(v, depth + 1, ancestors) }.reject { |v| v.equal?(OMIT) }
|
|
153
|
+
end
|
|
154
|
+
ensure
|
|
155
|
+
ancestors.delete(id)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
105
159
|
def stringify_values(hash)
|
|
106
160
|
return {} unless hash.is_a?(Hash)
|
|
107
161
|
|
data/lib/quonfig/version.rb
CHANGED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quonfig
|
|
4
|
+
# Internal test seam for the SDK's background waits (qfg-goi1.10.26).
|
|
5
|
+
#
|
|
6
|
+
# Every long background wait goes through here with a kind:
|
|
7
|
+
# :sse - SSE reconnect backoff (the unjittered base delay)
|
|
8
|
+
# :engage - fallback-poller grace after a mid-life SSE drop
|
|
9
|
+
# :poll - fallback / polling-mode poll interval
|
|
10
|
+
# :probe - auth-failed probe period (rule R3)
|
|
11
|
+
#
|
|
12
|
+
# A hook, when set, sees the REQUESTED seconds and returns the seconds to
|
|
13
|
+
# actually wait. Tests use it to record the requested values and compress
|
|
14
|
+
# time (supervisor-test-contract.md Test 8). It is not a customer option and
|
|
15
|
+
# is nil in production.
|
|
16
|
+
module Waits
|
|
17
|
+
class << self
|
|
18
|
+
attr_accessor :hook
|
|
19
|
+
|
|
20
|
+
def duration(kind, seconds)
|
|
21
|
+
h = hook
|
|
22
|
+
h ? h.call(kind, seconds) : seconds
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def sleep(kind, seconds)
|
|
26
|
+
Kernel.sleep(duration(kind, seconds))
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -95,6 +95,11 @@ module Quonfig
|
|
|
95
95
|
t
|
|
96
96
|
end
|
|
97
97
|
return if thread.nil?
|
|
98
|
+
# Called from the worker itself (the poller leaving auth-failed hands
|
|
99
|
+
# back to SSE, qfg-goi1.10.26): the flag is enough. The worker returns,
|
|
100
|
+
# run_loop sees stop? and exits without a restart. Raising into or
|
|
101
|
+
# joining the current thread would kill it mid-handoff.
|
|
102
|
+
return if thread == Thread.current
|
|
98
103
|
|
|
99
104
|
raise_shutdown(thread)
|
|
100
105
|
thread.join(SHUTDOWN_TIMEOUT_SEC)
|
data/lib/quonfig.rb
CHANGED
|
@@ -29,6 +29,7 @@ require 'quonfig/evaluation'
|
|
|
29
29
|
require 'quonfig/evaluation_details'
|
|
30
30
|
require 'quonfig/encryption'
|
|
31
31
|
require 'quonfig/exponential_backoff'
|
|
32
|
+
require 'quonfig/waits'
|
|
32
33
|
require 'quonfig/worker_supervisor'
|
|
33
34
|
require 'quonfig/periodic_sync'
|
|
34
35
|
require 'quonfig/errors/initialization_timeout_error'
|