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.
@@ -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
- def initialize(result, statuses, primary_host)
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
- def fetch_cycle!(log_mode: :normal)
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
- return FetchCycle.new(:failed, statuses, '') if urls.empty?
263
+ leg_errors ||= Concurrent::Map.new
264
+ return FetchCycle.new(:failed, statuses, '', leg_errors) if urls.empty?
215
265
 
216
- cycle_log = CycleLog.new(@logger, log_mode)
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, cycle_log: cycle_log)
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 #{source}"
495
+ @logger.debug "Configs not modified (304) from #{where}"
436
496
  :not_modified
437
497
  when 401, 403
438
- cycle_log.log(:warn, "Config fetch rejected (#{response.status}) from #{source}: #{short_body(response)}", auth: true)
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
- cycle_log.log(:info, "Config fetch failed: status #{response.status} from #{source}",
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 #{source} returned a non-envelope 200 (#{e.message}); treating as a failed leg")
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
- @logger.debug "Connection failure fetching configs from #{source}: #{e.message}"
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
- cycle_log.log(:warn, "Unexpected error fetching configs from #{source}: #{e.message}")
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
- Quonfig::ConfigEnvelope.new(
476
- configs: data['configs'] || [],
477
- meta: data['meta'] || {}
478
- )
479
- end
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
- new_keys = next_map.keys.to_set
570
- old_keys = @store.keys.to_set
571
- # Drop keys that disappeared server-side.
572
- (old_keys - new_keys).each { |k| @store.delete(k) } if @store.respond_to?(:delete)
573
-
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
- @store.set(key, cfg)
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
@@ -3,48 +3,94 @@
3
3
  module Quonfig
4
4
  # In-memory store of configs keyed by config key.
5
5
  #
6
- # Mirrors sdk-node's ConfigStore (src/store.ts). Integration tests and the
7
- # new Resolver/Evaluator trio construct this directly, independent of any
8
- # Client/ConfigLoader plumbing.
6
+ # Integration tests and the Resolver/Evaluator trio construct this
7
+ # directly, independent of any Client/ConfigLoader plumbing.
9
8
  #
10
- # Thread-safety: backed by Concurrent::Map, whose per-key reads, writes, and
11
- # deletes are atomic. There is no compound multi-key operation here that
12
- # needs an outer lock — envelope application in ConfigLoader is a sequence
13
- # of independent set/delete calls, and readers tolerate seeing the
14
- # in-progress mix. Eventual consistency across an envelope is acceptable
15
- # and matches sdk-node behavior.
9
+ # Thread-safety (qfg-i86q): the store holds one frozen Hash snapshot behind
10
+ # an AtomicReference. An install builds the next Hash off to the side and
11
+ # swaps the reference once (#replace_all), so a reader sees the old
12
+ # envelope or the new one, never a mix. Matches sdk-go runtime_store.go.
13
+ # #set / #delete / #clear are copy-on-write under a writer mutex, for the
14
+ # test harness and single-key callers.
15
+ #
16
+ # One lookup can read several keys (a flag, then the segments it
17
+ # references, then a decryptWith key). #pinned holds one snapshot for the
18
+ # current thread for the duration of a block, so every read in that
19
+ # lookup comes from the same envelope even if an install swaps the store
20
+ # mid-evaluation.
16
21
  class ConfigStore
22
+ EMPTY = {}.freeze
23
+ private_constant :EMPTY
24
+
17
25
  def initialize(initial_configs = nil)
18
- @configs = Concurrent::Map.new
26
+ @write_mutex = Mutex.new
27
+ @pin_key = :"__quonfig_store_pin_#{object_id}"
28
+ @snapshot = Concurrent::AtomicReference.new(EMPTY)
19
29
  return unless initial_configs
20
30
 
21
- initial_configs.each { |k, v| @configs[k] = v }
31
+ replace_all(initial_configs.to_h)
22
32
  end
23
33
 
24
34
  def get(key)
25
- @configs[key]
35
+ snapshot[key]
26
36
  end
27
37
 
28
38
  def set(key, config)
29
- @configs[key] = config
39
+ @write_mutex.synchronize do
40
+ @snapshot.set(@snapshot.get.merge(key => config).freeze)
41
+ end
42
+ config
30
43
  end
31
44
 
32
45
  def delete(key)
33
- @configs.delete(key)
46
+ @write_mutex.synchronize do
47
+ current = @snapshot.get
48
+ return nil unless current.key?(key)
49
+
50
+ next_map = current.dup
51
+ value = next_map.delete(key)
52
+ @snapshot.set(next_map.freeze)
53
+ value
54
+ end
34
55
  end
35
56
 
36
57
  def clear
37
- @configs.each_key { |k| @configs.delete(k) }
58
+ replace_all(EMPTY)
59
+ end
60
+
61
+ # Atomically replace every config with +configs+ (a Hash of key =>
62
+ # config). Readers see either the previous set or this one.
63
+ def replace_all(configs)
64
+ next_map = configs.frozen? ? configs : configs.dup.freeze
65
+ @write_mutex.synchronize { @snapshot.set(next_map) }
66
+ nil
38
67
  end
39
68
 
40
69
  def keys
41
- @configs.keys
70
+ snapshot.keys
42
71
  end
43
72
 
44
73
  def all_configs
45
- h = {}
46
- @configs.each_pair { |k, v| h[k] = v }
47
- h
74
+ snapshot.dup
75
+ end
76
+
77
+ # Run the block with this thread's reads pinned to the current snapshot.
78
+ # Re-entrant: a nested call keeps the outer pin.
79
+ def pinned
80
+ return yield if Thread.current[@pin_key]
81
+
82
+ Thread.current[@pin_key] = @snapshot.get
83
+ begin
84
+ yield
85
+ ensure
86
+ Thread.current[@pin_key] = nil
87
+ end
88
+ end
89
+
90
+ private
91
+
92
+ def snapshot
93
+ Thread.current[@pin_key] || @snapshot.get
48
94
  end
49
95
  end
50
96
  end
@@ -50,18 +50,18 @@ module Quonfig
50
50
  end
51
51
  end
52
52
 
53
+ version = "datadir:#{datadir}"
54
+ # Skip one malformed config file, keep the rest (qfg-krjt).
53
55
  Quonfig::ConfigEnvelope.new(
54
- configs: configs,
55
- meta: { 'version' => "datadir:#{datadir}", 'environment' => env_id }
56
+ configs: Quonfig::ConfigShape.filter(configs, version: version),
57
+ meta: { 'version' => version, 'environment' => env_id }
56
58
  )
57
59
  end
58
60
 
59
61
  # Convenience: load the envelope and populate a fresh ConfigStore.
60
62
  def load_store(datadir, environment = nil)
61
63
  envelope = load_envelope(datadir, environment)
62
- store = Quonfig::ConfigStore.new
63
- envelope.configs.each { |cfg| store.set(cfg['key'], cfg) }
64
- store
64
+ Quonfig::ConfigStore.new(envelope.configs.to_h { |cfg| [cfg['key'], cfg] })
65
65
  end
66
66
 
67
67
  def resolve_environment(quonfig_path, environment)
@@ -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
- class InitializationTimeoutError < Quonfig::Error
6
- def initialize(timeout_sec, key)
7
- message = "Quonfig couldn't initialize in #{timeout_sec} second timeout. Trying to fetch key `#{key}`."
8
- super(message)
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