quonfig 1.8.0 → 1.10.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 +38 -0
- data/README.md +44 -3
- data/lib/quonfig/client.rb +382 -33
- data/lib/quonfig/config_loader.rb +123 -38
- 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/errors/initialization_error.rb +31 -0
- data/lib/quonfig/errors/initialization_timeout_error.rb +9 -4
- data/lib/quonfig/evaluation_details.rb +3 -0
- data/lib/quonfig/http_connection.rb +1 -1
- data/lib/quonfig/internal_logger.rb +21 -14
- data/lib/quonfig/options.rb +50 -2
- 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 +2 -0
- metadata +4 -2
|
@@ -24,17 +24,54 @@ module Quonfig
|
|
|
24
24
|
# poller then enters auth-failed (Client#start_polling).
|
|
25
25
|
AUTH_STATUSES = [401, 403, 404, 410].freeze
|
|
26
26
|
|
|
27
|
+
# The leg error recorded when a leg ended on a timeout (its per-leg
|
|
28
|
+
# deadline or the hedge abort). The init loop reads it to tell a hung
|
|
29
|
+
# attempt from one that failed fast (init-failure design, decision 7).
|
|
30
|
+
LEG_TIMED_OUT = 'timed out'
|
|
31
|
+
|
|
32
|
+
# host:port of +url+ (scheme kept when present), never its userinfo, path
|
|
33
|
+
# or query. For log lines and leg errors (init-failure design, decision 9).
|
|
34
|
+
def self.display_url(url)
|
|
35
|
+
return url.to_s unless url.is_a?(String) || url.is_a?(URI::Generic)
|
|
36
|
+
|
|
37
|
+
uri = URI.parse(url.to_s)
|
|
38
|
+
return redact_userinfo(url.to_s) unless uri.host
|
|
39
|
+
|
|
40
|
+
"#{"#{uri.scheme}://" if uri.scheme}#{uri.host}:#{uri.port}"
|
|
41
|
+
rescue URI::InvalidURIError
|
|
42
|
+
redact_userinfo(url.to_s)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Strip "user:pass@" from every URL in +text+.
|
|
46
|
+
def self.redact_userinfo(text)
|
|
47
|
+
text.to_s.gsub(%r{(//)[^/@\s]+@}, '\\1')
|
|
48
|
+
end
|
|
49
|
+
|
|
27
50
|
# Result of one fetch cycle across the configured legs. +statuses+ maps a
|
|
28
51
|
# config_api_urls index to the HTTP status that leg returned (0 = no
|
|
29
52
|
# response: timeout, connection failure). +primary_host+ is host:port of
|
|
30
53
|
# the primary leg, for the entry ERROR (never the key).
|
|
31
54
|
class FetchCycle
|
|
32
|
-
attr_reader :result, :statuses, :primary_host
|
|
55
|
+
attr_reader :result, :statuses, :primary_host, :leg_errors
|
|
33
56
|
|
|
34
|
-
|
|
57
|
+
# +leg_errors+ maps a config_api_urls index to a one-line description of
|
|
58
|
+
# why that leg failed (host-free, key-free), for the init log contract.
|
|
59
|
+
def initialize(result, statuses, primary_host, leg_errors = {})
|
|
35
60
|
@result = result
|
|
36
61
|
@statuses = statuses
|
|
37
62
|
@primary_host = primary_host
|
|
63
|
+
@leg_errors = leg_errors
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Every leg answered and every answer was in the auth class (401/403,
|
|
67
|
+
# plus 404/410 per rule R1): the key is rejected (init-failure design
|
|
68
|
+
# B.3, "401/403 from both legs"). Stricter than #auth_rejected? (the
|
|
69
|
+
# poller's rule R2, primary leg only), because init short-circuits on it.
|
|
70
|
+
def all_legs_unauthorized?(leg_count)
|
|
71
|
+
return false unless @result == :failed
|
|
72
|
+
|
|
73
|
+
values = @statuses.values
|
|
74
|
+
values.size >= leg_count && values.all? { |s| AUTH_STATUSES.include?(s) }
|
|
38
75
|
end
|
|
39
76
|
|
|
40
77
|
def primary_status
|
|
@@ -66,8 +103,16 @@ module Quonfig
|
|
|
66
103
|
@mutex = Mutex.new
|
|
67
104
|
end
|
|
68
105
|
|
|
106
|
+
# Send this cycle's remaining lines to DEBUG. The init loop calls it when
|
|
107
|
+
# it gives up on an attempt that is still in flight: its legs keep running
|
|
108
|
+
# on their own threads up to the hedge abort, and their late failures are
|
|
109
|
+
# already summarized in the init ERROR (qfg-9p4i.10).
|
|
110
|
+
def silence!
|
|
111
|
+
@silenced = true
|
|
112
|
+
end
|
|
113
|
+
|
|
69
114
|
def log(level, msg, auth: false)
|
|
70
|
-
return @logger.debug(msg) if @mode == :probe
|
|
115
|
+
return @logger.debug(msg) if @mode == :probe || @silenced
|
|
71
116
|
|
|
72
117
|
if @mode == :poll && auth
|
|
73
118
|
hold = @mutex.synchronize do
|
|
@@ -208,27 +253,39 @@ module Quonfig
|
|
|
208
253
|
# leg's status (rule R2, qfg-goi1.10.26). +log_mode+ is one of CycleLog's
|
|
209
254
|
# modes; the poller passes :poll for an ordinary cycle and :probe while
|
|
210
255
|
# auth-failed.
|
|
211
|
-
|
|
256
|
+
#
|
|
257
|
+
# +leg_errors+ lets a caller pass in the map the legs record their errors
|
|
258
|
+
# into, so it can still read them if it abandons the cycle mid-flight (the
|
|
259
|
+
# init loop at init_timeout_ms).
|
|
260
|
+
def fetch_cycle!(log_mode: :normal, cycle_log: nil, leg_errors: nil)
|
|
212
261
|
urls = Array(@options.config_api_urls)
|
|
213
262
|
statuses = Concurrent::Map.new
|
|
214
|
-
|
|
263
|
+
leg_errors ||= Concurrent::Map.new
|
|
264
|
+
return FetchCycle.new(:failed, statuses, '', leg_errors) if urls.empty?
|
|
215
265
|
|
|
216
|
-
cycle_log
|
|
266
|
+
cycle_log ||= new_cycle_log(log_mode)
|
|
217
267
|
# Single leg (or no secondary configured): no hedge, just fetch on the
|
|
218
268
|
# calling thread under the SEQUENTIAL per-URL timeout (config_fetch_timeout_ms
|
|
219
269
|
# is unchanged and still governs any non-hedged path). Preserves the
|
|
220
270
|
# synchronous, single-request-per-call shape the legacy/mock callers depend
|
|
221
271
|
# on.
|
|
222
272
|
result = if urls.length < 2
|
|
223
|
-
fetch_from(urls[0], 0, timeout_ms: config_fetch_timeout_ms, statuses: statuses,
|
|
273
|
+
fetch_from(urls[0], 0, timeout_ms: config_fetch_timeout_ms, statuses: statuses,
|
|
274
|
+
cycle_log: cycle_log, leg_errors: leg_errors)
|
|
224
275
|
else
|
|
225
|
-
fetch_hedged(urls, statuses: statuses, cycle_log: cycle_log)
|
|
276
|
+
fetch_hedged(urls, statuses: statuses, cycle_log: cycle_log, leg_errors: leg_errors)
|
|
226
277
|
end
|
|
227
|
-
cycle = FetchCycle.new(result, statuses, host_of(urls[0]))
|
|
278
|
+
cycle = FetchCycle.new(result, statuses, host_of(urls[0]), leg_errors)
|
|
228
279
|
cycle_log.release(drop: cycle.auth_rejected?)
|
|
229
280
|
cycle
|
|
230
281
|
end
|
|
231
282
|
|
|
283
|
+
# A CycleLog over this loader's logger, for a caller that wants to hold on
|
|
284
|
+
# to it (see CycleLog#silence!).
|
|
285
|
+
def new_cycle_log(mode = :normal)
|
|
286
|
+
CycleLog.new(@logger, mode)
|
|
287
|
+
end
|
|
288
|
+
|
|
232
289
|
# Apply a ConfigEnvelope (from SSE) to the store. Called by the SSE client
|
|
233
290
|
# when a new event arrives. SSE is a single-leg live stream, so it carries no
|
|
234
291
|
# config_api_urls index and does not change #resolved_from — but it IS
|
|
@@ -265,7 +322,7 @@ module Quonfig
|
|
|
265
322
|
# the hedge abort inside fetch_from, and heals forward through the
|
|
266
323
|
# reject-older guard. We never join the slow leg — a hung primary must not
|
|
267
324
|
# block a successful secondary install.
|
|
268
|
-
def fetch_hedged(urls, statuses: nil, cycle_log: nil)
|
|
325
|
+
def fetch_hedged(urls, statuses: nil, cycle_log: nil, leg_errors: nil)
|
|
269
326
|
hedge_delay_s = hedge_delay_ms / 1000.0
|
|
270
327
|
abort_ms = hedge_abort_ms
|
|
271
328
|
|
|
@@ -293,7 +350,8 @@ module Quonfig
|
|
|
293
350
|
Thread.new do
|
|
294
351
|
result = :failed
|
|
295
352
|
begin
|
|
296
|
-
result = fetch_from(urls[index], index, timeout_ms: abort_ms, statuses: statuses, cycle_log: cycle_log
|
|
353
|
+
result = fetch_from(urls[index], index, timeout_ms: abort_ms, statuses: statuses, cycle_log: cycle_log,
|
|
354
|
+
leg_errors: leg_errors)
|
|
297
355
|
rescue StandardError => e
|
|
298
356
|
@logger.debug "Hedge leg #{index} failed: #{e.class}: #{e.message}"
|
|
299
357
|
ensure
|
|
@@ -402,8 +460,10 @@ module Quonfig
|
|
|
402
460
|
''
|
|
403
461
|
end
|
|
404
462
|
|
|
405
|
-
def fetch_from(source, index = nil, timeout_ms: nil, statuses: nil, cycle_log: nil)
|
|
463
|
+
def fetch_from(source, index = nil, timeout_ms: nil, statuses: nil, cycle_log: nil, leg_errors: nil)
|
|
406
464
|
cycle_log ||= CycleLog.new(@logger, :normal)
|
|
465
|
+
where = self.class.display_url(source)
|
|
466
|
+
note = ->(msg) { leg_errors[index || 0] = self.class.redact_userinfo(msg)[0, 200] if leg_errors }
|
|
407
467
|
# qfg-7h5d.1.9 / .1.14: bound this single per-leg attempt so a hung upstream
|
|
408
468
|
# aborts (Faraday::TimeoutError, caught below as :failed). On the hedged
|
|
409
469
|
# path the caller passes config_fetch_hedge_abort_ms; on the sequential /
|
|
@@ -432,30 +492,59 @@ module Quonfig
|
|
|
432
492
|
# an equal/older payload — surface that so the caller doesn't double-count.
|
|
433
493
|
result == :not_modified ? :not_modified : :updated
|
|
434
494
|
when 304
|
|
435
|
-
@logger.debug "Configs not modified (304) from #{
|
|
495
|
+
@logger.debug "Configs not modified (304) from #{where}"
|
|
436
496
|
:not_modified
|
|
437
497
|
when 401, 403
|
|
438
|
-
|
|
498
|
+
note.call("HTTP #{response.status} (SDK key rejected)")
|
|
499
|
+
# Never the response body: it is upstream content (decision 9).
|
|
500
|
+
cycle_log.log(:warn, "Config fetch rejected (#{response.status}) from #{where}", auth: true)
|
|
439
501
|
:failed
|
|
440
502
|
else
|
|
441
|
-
|
|
503
|
+
note.call("HTTP #{response.status}")
|
|
504
|
+
cycle_log.log(:info, "Config fetch failed: status #{response.status} from #{where}",
|
|
442
505
|
auth: AUTH_STATUSES.include?(response.status.to_i))
|
|
443
506
|
:failed
|
|
444
507
|
end
|
|
445
508
|
rescue NonEnvelopeError => e
|
|
509
|
+
note.call("non-envelope 200 (#{e.message})")
|
|
446
510
|
# qfg-9dxb.3: a non-envelope 200 is a leg error, NOT an install — the
|
|
447
511
|
# hedge/failover proceeds and this leg's ETag is never stored (so a junk
|
|
448
512
|
# 200 cannot pin itself through later 304s).
|
|
449
|
-
cycle_log.log(:warn, "Config fetch from #{
|
|
513
|
+
cycle_log.log(:warn, "Config fetch from #{where} returned a non-envelope 200 (#{e.message}); treating as a failed leg")
|
|
514
|
+
:failed
|
|
515
|
+
rescue Quonfig::ConfigShape::AllInvalidError => e
|
|
516
|
+
note.call(e.message)
|
|
517
|
+
# qfg-krjt: a payload with no loadable config is a leg error, so it can
|
|
518
|
+
# never install as an empty workspace.
|
|
519
|
+
cycle_log.log(:warn, "Config fetch from #{where}: #{e.message}; treating as a failed leg")
|
|
520
|
+
:failed
|
|
521
|
+
rescue JSON::ParserError
|
|
522
|
+
# The parser's message quotes the body; never pass it on (decision 9).
|
|
523
|
+
note.call('non-JSON 200 body')
|
|
524
|
+
cycle_log.log(:warn, "Config fetch from #{where} returned a non-JSON 200; treating as a failed leg")
|
|
525
|
+
:failed
|
|
526
|
+
rescue Faraday::TimeoutError
|
|
527
|
+
note.call(LEG_TIMED_OUT)
|
|
528
|
+
cycle_log.log(:warn, "Timeout error fetching configs from #{where}")
|
|
450
529
|
:failed
|
|
451
530
|
rescue Faraday::ConnectionFailed => e
|
|
452
|
-
|
|
531
|
+
# The Net::HTTP adapter wraps a connect timeout (Net::OpenTimeout) in
|
|
532
|
+
# ConnectionFailed. That is a hang, not a fast failure (decision 7).
|
|
533
|
+
note.call(connect_timeout?(e) ? LEG_TIMED_OUT : e.message)
|
|
534
|
+
@logger.debug "Connection failure fetching configs from #{where}: #{self.class.redact_userinfo(e.message)}"
|
|
453
535
|
:failed
|
|
454
536
|
rescue StandardError => e
|
|
455
|
-
|
|
537
|
+
note.call("#{e.class}: #{e.message}")
|
|
538
|
+
cycle_log.log(:warn, "Unexpected error fetching configs from #{where}: #{self.class.redact_userinfo(e.message)}")
|
|
456
539
|
:failed
|
|
457
540
|
end
|
|
458
541
|
|
|
542
|
+
def connect_timeout?(err)
|
|
543
|
+
cause = err.respond_to?(:wrapped_exception) ? err.wrapped_exception : nil
|
|
544
|
+
cause ||= err.cause
|
|
545
|
+
cause.is_a?(Timeout::Error) || cause.is_a?(Errno::ETIMEDOUT)
|
|
546
|
+
end
|
|
547
|
+
|
|
459
548
|
def etag_for(index)
|
|
460
549
|
@etag_mutex.synchronize { @etags[index || 0] }
|
|
461
550
|
end
|
|
@@ -472,17 +561,11 @@ module Quonfig
|
|
|
472
561
|
data = body.is_a?(String) ? JSON.parse(body) : body
|
|
473
562
|
raise NonEnvelopeError, 'missing meta.version' unless Quonfig::ConfigEnvelope.wire_envelope?(data)
|
|
474
563
|
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
)
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
def short_body(response)
|
|
482
|
-
return '' if response.body.nil?
|
|
483
|
-
|
|
484
|
-
str = response.body.to_s
|
|
485
|
-
str.length > 200 ? "#{str[0, 200]}..." : str
|
|
564
|
+
meta = data['meta'] || {}
|
|
565
|
+
# Skip one malformed config, keep the rest (qfg-krjt). Raises
|
|
566
|
+
# ConfigShape::AllInvalidError when none is loadable.
|
|
567
|
+
configs = Quonfig::ConfigShape.filter(data['configs'] || [], logger: @logger, version: meta['version'])
|
|
568
|
+
Quonfig::ConfigEnvelope.new(configs: configs, meta: meta)
|
|
486
569
|
end
|
|
487
570
|
|
|
488
571
|
def install_envelope(envelope, source:, source_index: nil)
|
|
@@ -517,12 +600,12 @@ module Quonfig
|
|
|
517
600
|
# Unversioned payload while a real generation is held: not provably
|
|
518
601
|
# older (it carries no ordering info), so a silent no-op — NOT
|
|
519
602
|
# counted as guardRejected.
|
|
520
|
-
@logger.debug "Unversioned payload ignored: held generation #{@held_generation} (source=#{source})"
|
|
603
|
+
@logger.debug "Unversioned payload ignored: held generation #{@held_generation} (source=#{self.class.display_url(source)})"
|
|
521
604
|
return :not_modified
|
|
522
605
|
end
|
|
523
606
|
|
|
524
607
|
if incoming_gen < @held_generation
|
|
525
|
-
@logger.debug "Reject-older guard: dropping incoming generation #{incoming_gen} < held #{@held_generation} (source=#{source})"
|
|
608
|
+
@logger.debug "Reject-older guard: dropping incoming generation #{incoming_gen} < held #{@held_generation} (source=#{self.class.display_url(source)})"
|
|
526
609
|
# Failover observability (qfg-41nh.18): count the guard rejection.
|
|
527
610
|
# This single guard covers BOTH the HTTP config-fetch path and the
|
|
528
611
|
# SSE message path (apply_envelope) — every network install funnels
|
|
@@ -542,7 +625,7 @@ module Quonfig
|
|
|
542
625
|
# `guardRejected` made a healthy client report failover activity from
|
|
543
626
|
# init alone. Not installed, not counted — but still :not_modified, so
|
|
544
627
|
# the caller's liveness stamp is unchanged.
|
|
545
|
-
@logger.debug "Same-generation re-delivery: ignoring incoming generation #{incoming_gen} (source=#{source})"
|
|
628
|
+
@logger.debug "Same-generation re-delivery: ignoring incoming generation #{incoming_gen} (source=#{self.class.display_url(source)})"
|
|
546
629
|
end
|
|
547
630
|
return :not_modified
|
|
548
631
|
end
|
|
@@ -565,18 +648,20 @@ module Quonfig
|
|
|
565
648
|
# (Thread#raise, Timeout) then leaves the held generation where it
|
|
566
649
|
# was, so the same-generation redelivery that follows installs again
|
|
567
650
|
# and repairs the store instead of being ignored as a duplicate.
|
|
651
|
+
#
|
|
652
|
+
# The new config set is built off to the side and swapped in with one
|
|
653
|
+
# reference write (qfg-i86q), so a concurrent lookup never sees a flag
|
|
654
|
+
# from this envelope with a segment from the last one, or a key that
|
|
655
|
+
# is momentarily absent.
|
|
568
656
|
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
|
-
|
|
657
|
+
next_configs = {}
|
|
574
658
|
envelope.configs.each do |cfg|
|
|
575
659
|
key = config_key(cfg)
|
|
576
660
|
next if key.nil?
|
|
577
661
|
|
|
578
|
-
|
|
662
|
+
next_configs[key] = cfg
|
|
579
663
|
end
|
|
664
|
+
@store.replace_all(next_configs)
|
|
580
665
|
end
|
|
581
666
|
|
|
582
667
|
# 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)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quonfig
|
|
4
|
+
module Errors
|
|
5
|
+
# Raised by Client.new under on_init_failure: :fail (the default) when no
|
|
6
|
+
# config could be loaded (qfg-9p4i.10; init-failure design B.4). The base
|
|
7
|
+
# of the init-failure family: rescue this to catch every init failure.
|
|
8
|
+
#
|
|
9
|
+
# +reason+ is one of:
|
|
10
|
+
# - +:unavailable+ - every attempt failed fast (refused, DNS, 5xx) until
|
|
11
|
+
# init_timeout_ms ran out
|
|
12
|
+
# - +:unauthorized+ - every delivery leg rejected the SDK key (401/403);
|
|
13
|
+
# raised at once, without waiting out the timeout
|
|
14
|
+
# - +:timeout+ - an attempt was still in flight at init_timeout_ms
|
|
15
|
+
# (InitializationTimeoutError)
|
|
16
|
+
#
|
|
17
|
+
# +errors+ is the per-leg summary (+primary=<host>: <error>; ...+). It names
|
|
18
|
+
# hosts only, never the SDK key.
|
|
19
|
+
class InitializationError < Quonfig::Error
|
|
20
|
+
REASONS = %i[unavailable unauthorized timeout].freeze
|
|
21
|
+
|
|
22
|
+
attr_reader :reason, :errors
|
|
23
|
+
|
|
24
|
+
def initialize(message = 'Quonfig failed to initialize', reason: :unavailable, errors: nil)
|
|
25
|
+
@reason = reason
|
|
26
|
+
@errors = errors
|
|
27
|
+
super(message)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -2,10 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
module Quonfig
|
|
4
4
|
module Errors
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
# The init-failure error for a hang: an attempt was still in flight when
|
|
6
|
+
# init_timeout_ms ran out. A subclass of InitializationError (reason
|
|
7
|
+
# +:timeout+), so rescuing either class catches it.
|
|
8
|
+
class InitializationTimeoutError < InitializationError
|
|
9
|
+
# +timeout_sec+ and +key+ keep the original positional signature.
|
|
10
|
+
def initialize(timeout_sec, key = nil, message: nil, errors: nil)
|
|
11
|
+
message ||= "Quonfig couldn't initialize in #{timeout_sec} second timeout."
|
|
12
|
+
message += " Trying to fetch key `#{key}`." if key
|
|
13
|
+
super(message, reason: :timeout, errors: errors)
|
|
9
14
|
end
|
|
10
15
|
end
|
|
11
16
|
end
|
|
@@ -16,6 +16,8 @@ module Quonfig
|
|
|
16
16
|
# +error_code+ (only when reason == "ERROR") is one of:
|
|
17
17
|
# "FLAG_NOT_FOUND" — the key was unknown to the store
|
|
18
18
|
# "TYPE_MISMATCH" — the resolved value didn't satisfy the requested type
|
|
19
|
+
# "PROVIDER_NOT_READY" — the client has no config yet (init continued
|
|
20
|
+
# on code defaults, or a caught init failure)
|
|
19
21
|
# "GENERAL" — any other failure
|
|
20
22
|
class EvaluationDetails
|
|
21
23
|
REASON_STATIC = 'STATIC'
|
|
@@ -27,6 +29,7 @@ module Quonfig
|
|
|
27
29
|
ERROR_FLAG_NOT_FOUND = 'FLAG_NOT_FOUND'
|
|
28
30
|
ERROR_TYPE_MISMATCH = 'TYPE_MISMATCH'
|
|
29
31
|
ERROR_GENERAL = 'GENERAL'
|
|
32
|
+
ERROR_PROVIDER_NOT_READY = 'PROVIDER_NOT_READY'
|
|
30
33
|
|
|
31
34
|
attr_reader :value, :reason, :error_code, :error_message, :variant, :flag_metadata
|
|
32
35
|
|
|
@@ -90,7 +90,7 @@ module Quonfig
|
|
|
90
90
|
|
|
91
91
|
deadline_s = (@timeout_ms / 1000.0) + WALL_CLOCK_HEADROOM_S
|
|
92
92
|
Timeout.timeout(deadline_s, Faraday::TimeoutError,
|
|
93
|
-
"wall-clock per-request deadline #{deadline_s}s exceeded for #{@uri}") do
|
|
93
|
+
"wall-clock per-request deadline #{deadline_s}s exceeded for #{Quonfig::ConfigLoader.redact_userinfo(@uri)}") do
|
|
94
94
|
block.call
|
|
95
95
|
end
|
|
96
96
|
end
|