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
data/lib/quonfig/client.rb
CHANGED
|
@@ -21,6 +21,12 @@ module Quonfig
|
|
|
21
21
|
class Client
|
|
22
22
|
LOG = Quonfig::InternalLogger.new(self)
|
|
23
23
|
|
|
24
|
+
# Init fetch loop cadence (qfg-9p4i.10; init-failure design B.3, C).
|
|
25
|
+
INIT_PROGRESS_EVERY_S = 5
|
|
26
|
+
INIT_WAIT_PROGRESS_EVERY_S = 30
|
|
27
|
+
INIT_BACKOFF_CAP_S = 30.0
|
|
28
|
+
private_constant :INIT_PROGRESS_EVERY_S, :INIT_WAIT_PROGRESS_EVERY_S, :INIT_BACKOFF_CAP_S
|
|
29
|
+
|
|
24
30
|
# Auth-failed probe period bounds, rule R3 (qfg-goi1.10.26).
|
|
25
31
|
AUTH_PROBE_MIN_SECONDS = 150.0
|
|
26
32
|
AUTH_PROBE_MAX_SECONDS = 300.0
|
|
@@ -62,6 +68,7 @@ module Quonfig
|
|
|
62
68
|
Quonfig::Options.new(option_kwargs)
|
|
63
69
|
end
|
|
64
70
|
Quonfig::InternalLogger.user_logger = @options.logger if @options.logger
|
|
71
|
+
warn_on_init_failure_spelling(@options)
|
|
65
72
|
@global_context = build_initial_global_context(@options)
|
|
66
73
|
@instance_hash = SecureRandom.uuid
|
|
67
74
|
@store = store || Quonfig::ConfigStore.new
|
|
@@ -129,7 +136,8 @@ module Quonfig
|
|
|
129
136
|
|
|
130
137
|
# Register only for non-store-injected clients (a caller-supplied store
|
|
131
138
|
# is the test/bootstrap path; the fork hook does not apply there).
|
|
132
|
-
|
|
139
|
+
# Always the base class registry: a subclass has no @instances of its own.
|
|
140
|
+
Quonfig::Client.register_instance(self) unless store
|
|
133
141
|
end
|
|
134
142
|
|
|
135
143
|
# ---- Lookup --------------------------------------------------------
|
|
@@ -621,6 +629,33 @@ module Quonfig
|
|
|
621
629
|
!last_successful_refresh.nil?
|
|
622
630
|
end
|
|
623
631
|
|
|
632
|
+
# Block until the client has config (see #ready?), up to +timeout_ms+ (nil
|
|
633
|
+
# = no bound). Returns true once ready, false if the timeout ran out or the
|
|
634
|
+
# client was stopped first (qfg-9p4i.10; init-failure design B.4). Never
|
|
635
|
+
# raises for an outage: under on_init_failure: :continue this is how a
|
|
636
|
+
# caller waits for the background retry to heal.
|
|
637
|
+
#
|
|
638
|
+
# In a forked child that has not used the client yet, this runs the
|
|
639
|
+
# child's re-initialization (as any first use does) and keeps waiting if
|
|
640
|
+
# that fails.
|
|
641
|
+
def wait_for_ready(timeout_ms: nil)
|
|
642
|
+
deadline = timeout_ms && (monotonic_now + (timeout_ms / 1000.0))
|
|
643
|
+
begin
|
|
644
|
+
ensure_initialized_after_fork
|
|
645
|
+
rescue Quonfig::Errors::InitializationError
|
|
646
|
+
nil
|
|
647
|
+
end
|
|
648
|
+
loop do
|
|
649
|
+
return true if ready?
|
|
650
|
+
return false if @stopped
|
|
651
|
+
return false if deadline && monotonic_now >= deadline
|
|
652
|
+
|
|
653
|
+
sleep WAIT_FOR_READY_POLL_S
|
|
654
|
+
end
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
WAIT_FOR_READY_POLL_S = 0.05
|
|
658
|
+
|
|
624
659
|
# Meta.generation of the currently-held envelope (0 before the first install
|
|
625
660
|
# or when the backend does not emit a generation). Canonical ordering: an
|
|
626
661
|
# established client never regresses to a lower generation.
|
|
@@ -794,6 +829,10 @@ module Quonfig
|
|
|
794
829
|
|
|
795
830
|
cancel_fallback_engage_timer
|
|
796
831
|
|
|
832
|
+
# Sleep-only thread with no I/O and nothing to drain: killing it is safe.
|
|
833
|
+
@not_ready_reporter&.kill
|
|
834
|
+
@not_ready_reporter = nil
|
|
835
|
+
|
|
797
836
|
begin
|
|
798
837
|
@poll_supervisor&.stop
|
|
799
838
|
rescue StandardError => e
|
|
@@ -829,6 +868,8 @@ module Quonfig
|
|
|
829
868
|
@telemetry_reporter = nil
|
|
830
869
|
@datadir_watcher = nil
|
|
831
870
|
@fallback_engage_timer = nil
|
|
871
|
+
@not_ready_reporter = nil
|
|
872
|
+
@init_heal_pending = false
|
|
832
873
|
|
|
833
874
|
# Dropping our reference is not enough for the reporter: its
|
|
834
875
|
# `Kernel.at_exit { final_drain_on_exit }` closure is process-wide, it
|
|
@@ -939,9 +980,9 @@ module Quonfig
|
|
|
939
980
|
end
|
|
940
981
|
end
|
|
941
982
|
|
|
942
|
-
return unless @options.on_init_failure == Quonfig::Options::ON_INITIALIZATION_FAILURE::
|
|
983
|
+
return unless @options.on_init_failure == Quonfig::Options::ON_INITIALIZATION_FAILURE::FAIL
|
|
943
984
|
|
|
944
|
-
# Parity with a fresh Client.new, which raises under :
|
|
985
|
+
# Parity with a fresh Client.new, which raises under :fail. Stored so
|
|
945
986
|
# later calls keep raising rather than re-running the fetch on every
|
|
946
987
|
# lookup.
|
|
947
988
|
@fork_rebuild_error = err
|
|
@@ -1049,7 +1090,18 @@ module Quonfig
|
|
|
1049
1090
|
# path that hands an envelope to the cache: datadir load, initial HTTP
|
|
1050
1091
|
# fetch, SSE event apply, and polling worker fetch.
|
|
1051
1092
|
def record_refresh!
|
|
1052
|
-
@state_mutex.synchronize
|
|
1093
|
+
healed_from = @state_mutex.synchronize do
|
|
1094
|
+
@last_successful_refresh = Time.now.utc
|
|
1095
|
+
next nil unless @init_heal_pending
|
|
1096
|
+
|
|
1097
|
+
# First install after an init that continued without config. Cleared
|
|
1098
|
+
# under the lock so racing installers log the heal exactly once.
|
|
1099
|
+
@init_heal_pending = false
|
|
1100
|
+
@init_started_at || monotonic_now
|
|
1101
|
+
end
|
|
1102
|
+
# The section C heal line, at WARN so the filter level that saw the
|
|
1103
|
+
# failure also sees the recovery. Logged outside the lock.
|
|
1104
|
+
log_init_recovered(healed_from) if healed_from
|
|
1053
1105
|
end
|
|
1054
1106
|
|
|
1055
1107
|
def sse_restart_total
|
|
@@ -1090,6 +1142,10 @@ module Quonfig
|
|
|
1090
1142
|
@sse_error_callback ||= ->(error) { handle_sse_error(error) }
|
|
1091
1143
|
end
|
|
1092
1144
|
|
|
1145
|
+
def sse_connected_callback
|
|
1146
|
+
@sse_connected_callback ||= -> { handle_sse_state_change(:connected) unless @stopped }
|
|
1147
|
+
end
|
|
1148
|
+
|
|
1093
1149
|
# Every SSE error, auth-class included, is an ordinary drop: SSE alone
|
|
1094
1150
|
# never enters auth-failed (rule R2, qfg-goi1.10.26; this reverses the
|
|
1095
1151
|
# qfg-i5xv latch). The fallback poller engages on its usual schedule and
|
|
@@ -1273,10 +1329,8 @@ module Quonfig
|
|
|
1273
1329
|
# propagates through the auto-reload path. Records a refresh timestamp.
|
|
1274
1330
|
# Caller is responsible for firing on_update.
|
|
1275
1331
|
def apply_datadir_envelope(envelope)
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
(old_keys - new_keys).each { |k| @store.delete(k) }
|
|
1279
|
-
envelope.configs.each { |cfg| @store.set(cfg['key'], cfg) }
|
|
1332
|
+
# One atomic swap, like a network install (qfg-i86q).
|
|
1333
|
+
@store.replace_all(envelope.configs.to_h { |cfg| [cfg['key'], cfg] })
|
|
1280
1334
|
# qfg-pinh: evaluate against the installed envelope's meta.environment,
|
|
1281
1335
|
# matching sdk-go. In datadir mode the loader stamps meta.environment =
|
|
1282
1336
|
# the resolved env (the `environment:` pin or QUONFIG_ENVIRONMENT), so
|
|
@@ -1336,6 +1390,7 @@ module Quonfig
|
|
|
1336
1390
|
warn_if_hedge_abort_exceeds_init_timeout
|
|
1337
1391
|
warn_if_explicit_api_urls_disables_failover
|
|
1338
1392
|
|
|
1393
|
+
@network_mode = true
|
|
1339
1394
|
# ||=: after a fork the child already built its loader over its fresh
|
|
1340
1395
|
# store (see #reset_store_in_child!).
|
|
1341
1396
|
@config_loader ||= Quonfig::ConfigLoader.new(@store, @options, failover_aggregator: @failover_aggregator)
|
|
@@ -1365,29 +1420,285 @@ module Quonfig
|
|
|
1365
1420
|
start_polling if @options.enable_polling && !sse_started
|
|
1366
1421
|
end
|
|
1367
1422
|
|
|
1423
|
+
# The init call's fetch loop (qfg-9p4i.10; init-failure design B.3 and the
|
|
1424
|
+
# section C log contract). Attempts the hedged primary+secondary fetch, and
|
|
1425
|
+
# between failed attempts sleeps a jittered 0.5s, 1s, 2s ... (30s cap) until
|
|
1426
|
+
# one installs. At init_timeout_ms, on_init_failure decides:
|
|
1427
|
+
#
|
|
1428
|
+
# - :fail raise InitializationError (reason :unavailable), or
|
|
1429
|
+
# InitializationTimeoutError when an attempt was still in flight
|
|
1430
|
+
# - :wait keep going with no upper bound
|
|
1431
|
+
# - :continue return over the empty store; the update channel started by
|
|
1432
|
+
# the caller keeps retrying and heals it
|
|
1433
|
+
#
|
|
1434
|
+
# Every leg answering an auth-class status (401/403/404/410) short-circuits
|
|
1435
|
+
# :fail and :continue at once; :wait logs it and keeps retrying (the key
|
|
1436
|
+
# may be provisioned late).
|
|
1437
|
+
#
|
|
1438
|
+
# init_timeout_ms <= 0 runs exactly one attempt, lets it finish (bounded by
|
|
1439
|
+
# the per-leg fetch timeouts, not the init deadline), then applies
|
|
1440
|
+
# on_init_failure. :wait keeps retrying after it (decision 8, qfg-9p4i.17).
|
|
1368
1441
|
def perform_initial_fetch
|
|
1369
|
-
|
|
1370
|
-
|
|
1442
|
+
mode = @options.on_init_failure
|
|
1443
|
+
timeout_ms = @options.init_timeout_ms || Quonfig::Options::DEFAULT_INIT_TIMEOUT_MS
|
|
1444
|
+
timeout_ms = 0 if timeout_ms.negative?
|
|
1445
|
+
one_shot = timeout_ms.zero?
|
|
1446
|
+
timeout_s = timeout_ms / 1000.0
|
|
1447
|
+
start = monotonic_now
|
|
1448
|
+
@init_started_at = start
|
|
1449
|
+
deadline = start + timeout_s
|
|
1450
|
+
urls = Array(@options.config_api_urls)
|
|
1451
|
+
LOG.debug "[quonfig] init: fetching config (initTimeout=#{format_secs(timeout_s)}, onInitFailure=#{mode})",
|
|
1452
|
+
event: 'quonfig.init.start'
|
|
1453
|
+
|
|
1454
|
+
waiting = mode == Quonfig::Options::ON_INITIALIZATION_FAILURE::WAIT
|
|
1455
|
+
attempt = 0
|
|
1456
|
+
errs = ''
|
|
1457
|
+
next_progress = start + init_progress_every_s
|
|
1458
|
+
timed_out_logged = false
|
|
1459
|
+
|
|
1460
|
+
# The section C progress lines, on their own cadence: called after every
|
|
1461
|
+
# attempt and at each wake-up inside the backoff sleep.
|
|
1462
|
+
report = lambda do |now|
|
|
1463
|
+
if waiting && !timed_out_logged && now >= deadline
|
|
1464
|
+
timed_out_logged = true
|
|
1465
|
+
next_progress = now + INIT_WAIT_PROGRESS_EVERY_S
|
|
1466
|
+
LOG.warn "[quonfig] init: no config after #{format('%.1f', now - start)}s. onInitFailure=wait: still " \
|
|
1467
|
+
"blocking startup and retrying indefinitely. #{errs}. Use onInitFailure=fail or continue to bound startup.",
|
|
1468
|
+
event: 'quonfig.init.wait'
|
|
1469
|
+
end
|
|
1470
|
+
next unless now >= next_progress
|
|
1471
|
+
next unless now < deadline || waiting
|
|
1371
1472
|
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1473
|
+
if now < deadline
|
|
1474
|
+
LOG.warn "[quonfig] init: still waiting for config (#{format('%.1f', now - start)}s elapsed of " \
|
|
1475
|
+
"#{format_secs(timeout_s)}, attempt #{attempt}). #{errs}", event: 'quonfig.init.waiting'
|
|
1476
|
+
next_progress += init_progress_every_s while next_progress <= now
|
|
1477
|
+
else
|
|
1478
|
+
LOG.warn "[quonfig] init: still blocked waiting for config (#{(now - start).round}s elapsed, " \
|
|
1479
|
+
"attempt #{attempt}). #{errs}", event: 'quonfig.init.waiting'
|
|
1480
|
+
next_progress += INIT_WAIT_PROGRESS_EVERY_S while next_progress <= now
|
|
1375
1481
|
end
|
|
1376
|
-
rescue Timeout::Error
|
|
1377
|
-
handle_init_failure(
|
|
1378
|
-
Quonfig::Errors::InitializationTimeoutError.new(timeout, nil)
|
|
1379
|
-
)
|
|
1380
|
-
return
|
|
1381
1482
|
end
|
|
1382
1483
|
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1484
|
+
loop do
|
|
1485
|
+
return if @stopped
|
|
1486
|
+
|
|
1487
|
+
attempt += 1
|
|
1488
|
+
cycle = nil
|
|
1489
|
+
cycle_log = @config_loader.new_cycle_log
|
|
1490
|
+
leg_errors = Concurrent::Map.new
|
|
1491
|
+
begin
|
|
1492
|
+
if waiting || one_shot
|
|
1493
|
+
cycle = @config_loader.fetch_cycle!(cycle_log: cycle_log, leg_errors: leg_errors)
|
|
1494
|
+
else
|
|
1495
|
+
# Never 0: Timeout.timeout(0) means "no timeout".
|
|
1496
|
+
budget = [deadline - monotonic_now, 0.001].max
|
|
1497
|
+
Timeout.timeout(budget) { cycle = @config_loader.fetch_cycle!(cycle_log: cycle_log, leg_errors: leg_errors) }
|
|
1498
|
+
end
|
|
1499
|
+
rescue Timeout::Error
|
|
1500
|
+
# An attempt was still in flight at init_timeout_ms. Its legs run on
|
|
1501
|
+
# until the hedge abort; their late lines go to DEBUG. Keep the leg
|
|
1502
|
+
# errors already known: a leg that failed fast means the attempt did
|
|
1503
|
+
# not hang, so the reason is :unavailable (decision 7).
|
|
1504
|
+
cycle_log.silence!
|
|
1505
|
+
errs = format_leg_errors(urls, leg_errors, in_flight: true)
|
|
1506
|
+
return init_timed_out(mode, timeout_s, errs, hung: attempt_hung?(urls, leg_errors))
|
|
1507
|
+
end
|
|
1508
|
+
|
|
1509
|
+
if cycle.result != :failed
|
|
1510
|
+
log_init_recovered(start, attempt) if attempt > 1
|
|
1511
|
+
sync_evaluator_env_id!
|
|
1512
|
+
record_refresh!
|
|
1513
|
+
return
|
|
1514
|
+
end
|
|
1515
|
+
|
|
1516
|
+
errs = format_leg_errors(urls, cycle.leg_errors)
|
|
1517
|
+
last_hung = attempt_hung?(urls, cycle.leg_errors)
|
|
1518
|
+
now = monotonic_now
|
|
1519
|
+
if cycle.all_legs_unauthorized?(urls.length)
|
|
1520
|
+
# Every leg is auth-class here (decision 6); report the primary's.
|
|
1521
|
+
status = cycle.primary_status
|
|
1522
|
+
legs = leg_names_phrase(urls.length)
|
|
1523
|
+
if waiting
|
|
1524
|
+
LOG.error "[quonfig] init: SDK key rejected (#{status}) by #{legs}. Check the key and " \
|
|
1525
|
+
"environment. onInitFailure=wait: still retrying. #{errs}", event: 'quonfig.init.unauthorized'
|
|
1526
|
+
else
|
|
1527
|
+
msg = "[quonfig] init FAILED: SDK key rejected (#{status}) by #{legs}. Check the key " \
|
|
1528
|
+
"and environment. onInitFailure=#{mode}: " \
|
|
1529
|
+
"#{mode == Quonfig::Options::ON_INITIALIZATION_FAILURE::FAIL ? 'raising now' : 'starting on code defaults now'}."
|
|
1530
|
+
LOG.error msg, event: 'quonfig.init.unauthorized'
|
|
1531
|
+
return init_continue_on_defaults(errs) if mode == Quonfig::Options::ON_INITIALIZATION_FAILURE::CONTINUE
|
|
1532
|
+
|
|
1533
|
+
raise Quonfig::Errors::InitializationError.new(
|
|
1534
|
+
"Quonfig init failed: SDK key rejected (#{status}) by #{legs}. #{errs}",
|
|
1535
|
+
reason: :unauthorized, errors: errs
|
|
1536
|
+
)
|
|
1537
|
+
end
|
|
1538
|
+
elsif attempt == 1 && !one_shot
|
|
1539
|
+
LOG.warn "[quonfig] init: config not loaded yet, retrying until initTimeout (#{format_secs(timeout_s)}). #{errs}",
|
|
1540
|
+
event: 'quonfig.init.retrying'
|
|
1541
|
+
end
|
|
1542
|
+
|
|
1543
|
+
if !waiting && now >= deadline
|
|
1544
|
+
# A one-shot attempt reports how long it actually took.
|
|
1545
|
+
elapsed_s = one_shot ? now - start : timeout_s
|
|
1546
|
+
return init_timed_out(mode, elapsed_s, errs, hung: last_hung)
|
|
1547
|
+
end
|
|
1548
|
+
|
|
1549
|
+
report.call(now)
|
|
1550
|
+
|
|
1551
|
+
# Decision 10 (sdk-go's rule): a new attempt starts whenever time
|
|
1552
|
+
# remains before the deadline, however little. If the deadline cuts it
|
|
1553
|
+
# off, it is the last attempt and decides the reason (the rescue
|
|
1554
|
+
# above); if the deadline lands in this backoff, this attempt decides.
|
|
1555
|
+
wake = now + init_backoff_s(attempt)
|
|
1556
|
+
wake = deadline if !waiting && wake > deadline
|
|
1557
|
+
wake_points = -> { [next_progress, (deadline if waiting && !timed_out_logged)].compact }
|
|
1558
|
+
init_sleep_until(wake, wake_points) { |t| report.call(t) }
|
|
1559
|
+
return init_timed_out(mode, timeout_s, errs, hung: last_hung) if !waiting && monotonic_now >= deadline
|
|
1560
|
+
end
|
|
1561
|
+
end
|
|
1562
|
+
|
|
1563
|
+
# Sleep until +wake+, waking early at each of +wake_points+ (re-read every
|
|
1564
|
+
# time) to yield the current time, so progress lines keep their cadence
|
|
1565
|
+
# through a long backoff. Returns early once the client is stopped.
|
|
1566
|
+
def init_sleep_until(wake, wake_points)
|
|
1567
|
+
loop do
|
|
1568
|
+
return if @stopped
|
|
1569
|
+
|
|
1570
|
+
now = monotonic_now
|
|
1571
|
+
return if now >= wake
|
|
1572
|
+
|
|
1573
|
+
nxt = (wake_points.call.select { |t| t > now } << wake).min
|
|
1574
|
+
sleep(nxt - now)
|
|
1575
|
+
yield monotonic_now
|
|
1388
1576
|
end
|
|
1389
1577
|
end
|
|
1390
1578
|
|
|
1579
|
+
# Seconds between "still waiting for config" lines before the timeout.
|
|
1580
|
+
def init_progress_every_s
|
|
1581
|
+
INIT_PROGRESS_EVERY_S
|
|
1582
|
+
end
|
|
1583
|
+
|
|
1584
|
+
# "primary", "primary and secondary", "primary, secondary and url2".
|
|
1585
|
+
def leg_names_phrase(count)
|
|
1586
|
+
names = Array.new([count, 1].max) { |i| leg_label(i) }
|
|
1587
|
+
names.length == 1 ? names.first : "#{names[0..-2].join(', ')} and #{names.last}"
|
|
1588
|
+
end
|
|
1589
|
+
|
|
1590
|
+
def leg_label(index)
|
|
1591
|
+
case index
|
|
1592
|
+
when 0 then 'primary'
|
|
1593
|
+
when 1 then 'secondary'
|
|
1594
|
+
else "url#{index}"
|
|
1595
|
+
end
|
|
1596
|
+
end
|
|
1597
|
+
|
|
1598
|
+
# 0.5s, 1s, 2s, 4s ... capped at 30s, jittered +/-20%.
|
|
1599
|
+
def init_backoff_s(attempt)
|
|
1600
|
+
base = [0.5 * (2**(attempt - 1)), INIT_BACKOFF_CAP_S].min
|
|
1601
|
+
base * (0.8 + (rand * 0.4))
|
|
1602
|
+
end
|
|
1603
|
+
|
|
1604
|
+
def init_timed_out(mode, timeout_s, errs, hung:)
|
|
1605
|
+
elapsed = format('%.1f', timeout_s)
|
|
1606
|
+
if mode == Quonfig::Options::ON_INITIALIZATION_FAILURE::CONTINUE
|
|
1607
|
+
LOG.error "[quonfig] init: no config after #{elapsed}s. onInitFailure=continue: STARTING ON CODE DEFAULTS. " \
|
|
1608
|
+
'This instance may disagree with the rest of your fleet. Retrying in background.',
|
|
1609
|
+
event: 'quonfig.init.continue'
|
|
1610
|
+
return init_continue_on_defaults(errs)
|
|
1611
|
+
end
|
|
1612
|
+
|
|
1613
|
+
LOG.error "[quonfig] init FAILED: no config after #{elapsed}s. #{errs}. onInitFailure=fail: raising " \
|
|
1614
|
+
'Quonfig::Errors::InitializationError. Options: on_init_failure: :wait to keep waiting, ' \
|
|
1615
|
+
'on_init_failure: :continue to start on code defaults, or raise init_timeout_ms.',
|
|
1616
|
+
event: 'quonfig.init.failed'
|
|
1617
|
+
message = "Quonfig init failed: no config after #{elapsed}s. #{errs}"
|
|
1618
|
+
raise Quonfig::Errors::InitializationTimeoutError.new(timeout_s, nil, message: message, errors: errs) if hung
|
|
1619
|
+
|
|
1620
|
+
raise Quonfig::Errors::InitializationError.new(message, reason: :unavailable, errors: errs)
|
|
1621
|
+
end
|
|
1622
|
+
|
|
1623
|
+
# :continue over an empty store. The update channel the caller starts next
|
|
1624
|
+
# keeps retrying; #record_refresh! logs the heal, and the not-ready reporter
|
|
1625
|
+
# logs the outage on the section C cadence until then.
|
|
1626
|
+
def init_continue_on_defaults(errs)
|
|
1627
|
+
@state_mutex.synchronize do
|
|
1628
|
+
@init_heal_pending = true
|
|
1629
|
+
@init_last_errs = errs
|
|
1630
|
+
end
|
|
1631
|
+
start_not_ready_reporter
|
|
1632
|
+
nil
|
|
1633
|
+
end
|
|
1634
|
+
|
|
1635
|
+
# WARN every 30s for 5 minutes, then every 5 minutes, while a :continue
|
|
1636
|
+
# client has no config (section C, "Background retry while not ready").
|
|
1637
|
+
def start_not_ready_reporter
|
|
1638
|
+
return if @not_ready_reporter&.alive?
|
|
1639
|
+
|
|
1640
|
+
@not_ready_reporter = Thread.new do
|
|
1641
|
+
Thread.current.name = 'quonfig-not-ready-reporter' if Thread.current.respond_to?(:name=)
|
|
1642
|
+
loop do
|
|
1643
|
+
elapsed = monotonic_now - @init_started_at
|
|
1644
|
+
sleep(elapsed < 300 ? 30 : 300)
|
|
1645
|
+
break if @stopped || ready?
|
|
1646
|
+
|
|
1647
|
+
since = (monotonic_now - @init_started_at).round
|
|
1648
|
+
errs = @state_mutex.synchronize { @init_last_errs }
|
|
1649
|
+
LOG.warn "[quonfig] still not connected to Quonfig delivery (#{since / 60}m#{since % 60}s since start); " \
|
|
1650
|
+
"serving defaults. #{errs}", event: 'quonfig.init.not_connected'
|
|
1651
|
+
end
|
|
1652
|
+
rescue StandardError
|
|
1653
|
+
nil
|
|
1654
|
+
end
|
|
1655
|
+
end
|
|
1656
|
+
|
|
1657
|
+
def log_init_recovered(start, _attempt = nil)
|
|
1658
|
+
via = @config_loader.respond_to?(:resolved_from) ? @config_loader.resolved_from : ''
|
|
1659
|
+
# A stream install leaves resolved_from empty: name the SSE leg.
|
|
1660
|
+
via = sse_failed_over_to_secondary? ? leg_label(1) : leg_label(0) if via.to_s.empty?
|
|
1661
|
+
version = @config_loader.respond_to?(:version) ? @config_loader.version : nil
|
|
1662
|
+
LOG.warn "[quonfig] init recovered: config loaded after #{format('%.1f', monotonic_now - start)}s via #{via}" \
|
|
1663
|
+
"#{" (version #{version})" if version}. Now serving live config.", event: 'quonfig.init.recovered'
|
|
1664
|
+
end
|
|
1665
|
+
|
|
1666
|
+
# Whether an attempt hung (decision 7): no leg failed fast. Every leg that
|
|
1667
|
+
# settled ended on a timeout, and a leg with no error yet was still in
|
|
1668
|
+
# flight when the init deadline cut the attempt off.
|
|
1669
|
+
def attempt_hung?(urls, leg_errors)
|
|
1670
|
+
urls.each_index.all? do |i|
|
|
1671
|
+
err = leg_errors[i]
|
|
1672
|
+
err.nil? || err == Quonfig::ConfigLoader::LEG_TIMED_OUT
|
|
1673
|
+
end
|
|
1674
|
+
end
|
|
1675
|
+
|
|
1676
|
+
# primary=<host>: <error>; secondary=<host>: <error>. Hosts only, never the
|
|
1677
|
+
# SDK key or URL credentials.
|
|
1678
|
+
def format_leg_errors(urls, leg_errors, in_flight: false)
|
|
1679
|
+
urls.each_with_index.map do |url, i|
|
|
1680
|
+
label = leg_label(i)
|
|
1681
|
+
err = leg_errors[i] || (in_flight ? 'no response before initTimeout' : 'no response')
|
|
1682
|
+
"#{label}=#{safe_host(url)}: #{err}"
|
|
1683
|
+
end.join('; ')
|
|
1684
|
+
end
|
|
1685
|
+
|
|
1686
|
+
def safe_host(url)
|
|
1687
|
+
uri = URI.parse(url.to_s)
|
|
1688
|
+
uri.host ? "#{uri.host}:#{uri.port}" : '?'
|
|
1689
|
+
rescue URI::InvalidURIError
|
|
1690
|
+
'?'
|
|
1691
|
+
end
|
|
1692
|
+
|
|
1693
|
+
# "10s" for whole seconds, "0.5s" otherwise.
|
|
1694
|
+
def format_secs(secs)
|
|
1695
|
+
secs == secs.to_i ? "#{secs.to_i}s" : "#{secs}s"
|
|
1696
|
+
end
|
|
1697
|
+
|
|
1698
|
+
def monotonic_now
|
|
1699
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
1700
|
+
end
|
|
1701
|
+
|
|
1391
1702
|
# qfg-pinh: In SDK-key DELIVERY mode the server's `meta.environment` is
|
|
1392
1703
|
# AUTHORITATIVE. The server scopes each config to a single environment and
|
|
1393
1704
|
# reports the active env id in `meta.environment` (the loader captures it
|
|
@@ -1429,6 +1740,26 @@ module Quonfig
|
|
|
1429
1740
|
)
|
|
1430
1741
|
end
|
|
1431
1742
|
|
|
1743
|
+
DEPRECATED_INIT_FAILURE_WARNED = Set.new
|
|
1744
|
+
DEPRECATED_INIT_FAILURE_MUTEX = Mutex.new
|
|
1745
|
+
private_constant :DEPRECATED_INIT_FAILURE_WARNED, :DEPRECATED_INIT_FAILURE_MUTEX
|
|
1746
|
+
|
|
1747
|
+
# One WARN per process per deprecated spelling (section C), and a WARN for
|
|
1748
|
+
# an unrecognized value, which runs as :fail.
|
|
1749
|
+
def warn_on_init_failure_spelling(options)
|
|
1750
|
+
if (bad = options.on_init_failure_unknown)
|
|
1751
|
+
LOG.warn "[quonfig] unknown on_init_failure #{bad.inspect}; using :fail (expected :fail, :wait or :continue)"
|
|
1752
|
+
end
|
|
1753
|
+
old = options.on_init_failure_alias
|
|
1754
|
+
return if old.nil?
|
|
1755
|
+
|
|
1756
|
+
first = DEPRECATED_INIT_FAILURE_MUTEX.synchronize { DEPRECATED_INIT_FAILURE_WARNED.add?(old) }
|
|
1757
|
+
return unless first
|
|
1758
|
+
|
|
1759
|
+
LOG.warn "[quonfig] on_init_failure=:#{old} is deprecated; use :#{options.on_init_failure}",
|
|
1760
|
+
event: 'quonfig.init.deprecated_alias'
|
|
1761
|
+
end
|
|
1762
|
+
|
|
1432
1763
|
# qfg-7h5d.1.14: the per-leg hedge abort MUST be < init_timeout_ms, otherwise
|
|
1433
1764
|
# the init-path heal leg is clipped by the overall init deadline before it can
|
|
1434
1765
|
# heal forward. Mirrors sdk-go's construction-time warning in options.go. Warn
|
|
@@ -1443,6 +1774,8 @@ module Quonfig
|
|
|
1443
1774
|
abort_ms = @options.config_fetch_hedge_abort_ms
|
|
1444
1775
|
init_ms = @options.init_timeout_ms
|
|
1445
1776
|
return if abort_ms.nil? || init_ms.nil?
|
|
1777
|
+
# <= 0 runs one attempt to completion; no init deadline clips its legs.
|
|
1778
|
+
return if init_ms <= 0
|
|
1446
1779
|
return if init_ms > abort_ms
|
|
1447
1780
|
|
|
1448
1781
|
LOG.warn(
|
|
@@ -1474,15 +1807,6 @@ module Quonfig
|
|
|
1474
1807
|
)
|
|
1475
1808
|
end
|
|
1476
1809
|
|
|
1477
|
-
def handle_init_failure(err)
|
|
1478
|
-
if @options.on_init_failure == Quonfig::Options::ON_INITIALIZATION_FAILURE::RETURN
|
|
1479
|
-
LOG.warn "[quonfig] Initialization did not complete cleanly; continuing with empty store: #{err.message}"
|
|
1480
|
-
return
|
|
1481
|
-
end
|
|
1482
|
-
|
|
1483
|
-
raise err
|
|
1484
|
-
end
|
|
1485
|
-
|
|
1486
1810
|
# Returns true if SSE started successfully, false otherwise. A false here
|
|
1487
1811
|
# signals the caller to fall back to polling.
|
|
1488
1812
|
def start_sse
|
|
@@ -1494,7 +1818,12 @@ module Quonfig
|
|
|
1494
1818
|
@config_loader,
|
|
1495
1819
|
nil,
|
|
1496
1820
|
nil,
|
|
1497
|
-
on_error: sse_error_callback
|
|
1821
|
+
on_error: sse_error_callback,
|
|
1822
|
+
# First-frame rule (qfg-goi1.10.43): connected at the connection's
|
|
1823
|
+
# first valid frame, keepalive included, never on the 200 headers.
|
|
1824
|
+
# Cancels the engage timer / disengages the poller without stamping
|
|
1825
|
+
# last_successful_refresh (only an install or fetch does that).
|
|
1826
|
+
on_connected: sse_connected_callback
|
|
1498
1827
|
)
|
|
1499
1828
|
@sse_client.start do |envelope, _event, _source|
|
|
1500
1829
|
next if @stopped
|
|
@@ -1755,6 +2084,25 @@ module Quonfig
|
|
|
1755
2084
|
nil
|
|
1756
2085
|
end
|
|
1757
2086
|
|
|
2087
|
+
# A delivery-mode client that has never installed config (init continued
|
|
2088
|
+
# on code defaults, or the caller caught a failed init): the *_details
|
|
2089
|
+
# getters answer ERROR / PROVIDER_NOT_READY (init-failure design B.3)
|
|
2090
|
+
# rather than claiming the key does not exist.
|
|
2091
|
+
def provider_not_ready?
|
|
2092
|
+
@network_mode && !ready?
|
|
2093
|
+
end
|
|
2094
|
+
|
|
2095
|
+
def provider_not_ready_details
|
|
2096
|
+
Quonfig::EvaluationDetails.new(
|
|
2097
|
+
value: nil,
|
|
2098
|
+
reason: Quonfig::EvaluationDetails::REASON_ERROR,
|
|
2099
|
+
error_code: Quonfig::EvaluationDetails::ERROR_PROVIDER_NOT_READY,
|
|
2100
|
+
error_message: 'Quonfig has not loaded config yet',
|
|
2101
|
+
variant: build_variant(Quonfig::EvaluationDetails::REASON_ERROR, nil, nil),
|
|
2102
|
+
flag_metadata: build_flag_metadata(nil, nil, nil, nil, nil)
|
|
2103
|
+
)
|
|
2104
|
+
end
|
|
2105
|
+
|
|
1758
2106
|
# Build a Quonfig::EvaluationDetails for +key+, evaluated against the
|
|
1759
2107
|
# caller's context, after coercing/checking +expected_type+. Never
|
|
1760
2108
|
# raises; all exceptions become ERROR details.
|
|
@@ -1763,6 +2111,7 @@ module Quonfig
|
|
|
1763
2111
|
jit = context == NO_DEFAULT_PROVIDED ? nil : context
|
|
1764
2112
|
ctx = build_context(jit)
|
|
1765
2113
|
record_context_for_telemetry(ctx)
|
|
2114
|
+
return provider_not_ready_details if provider_not_ready?
|
|
1766
2115
|
|
|
1767
2116
|
result =
|
|
1768
2117
|
begin
|