quonfig 1.7.2 → 1.9.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 +41 -0
- data/README.md +24 -0
- data/lib/quonfig/client.rb +158 -47
- data/lib/quonfig/config_loader.rb +141 -30
- 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/env_var_parse_error.rb +10 -3
- data/lib/quonfig/evaluator.rb +72 -14
- data/lib/quonfig/resolver.rb +47 -23
- data/lib/quonfig/semver.rb +11 -1
- data/lib/quonfig/sse_config_client.rb +229 -87
- 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/weighted_value_resolver.rb +3 -1
- data/lib/quonfig/worker_supervisor.rb +5 -0
- data/lib/quonfig.rb +2 -0
- metadata +4 -2
|
@@ -34,10 +34,12 @@ module Quonfig
|
|
|
34
34
|
#
|
|
35
35
|
# sse_initial_reconnect_delay / sse_max_reconnect_delay: backoff bounds.
|
|
36
36
|
# Each failed reconnect doubles the delay (with +/-50% jitter) up to the
|
|
37
|
-
# max. A
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
# connections is normal; the
|
|
37
|
+
# max. A connection that delivers its first valid frame (a config
|
|
38
|
+
# envelope or a keepalive) resets the delay to the initial value. A
|
|
39
|
+
# clean server-initiated FIN after such a frame is "not a failure for
|
|
40
|
+
# backoff purposes" because LBs recycling connections is normal; the
|
|
41
|
+
# reconnect counter still increments. A connection that ends before any
|
|
42
|
+
# frame is a failed attempt (qfg-goi1.10.43).
|
|
41
43
|
def initialize(sse_read_timeout: 90,
|
|
42
44
|
sse_connect_timeout: 10,
|
|
43
45
|
sse_initial_reconnect_delay: 1.0,
|
|
@@ -51,10 +53,17 @@ module Quonfig
|
|
|
51
53
|
|
|
52
54
|
LOG = Quonfig::InternalLogger.new(self)
|
|
53
55
|
|
|
54
|
-
#
|
|
55
|
-
#
|
|
56
|
-
#
|
|
57
|
-
|
|
56
|
+
# Auth-class statuses (rule R1, qfg-goi1.10.26). On the stream they keep the
|
|
57
|
+
# normal reconnect ladder: SSE alone never stops retrying and never enters
|
|
58
|
+
# auth-failed (R2); the client's fallback poller decides that from a whole
|
|
59
|
+
# fetch cycle. They only get their own log line: WARN once per status,
|
|
60
|
+
# re-armed by a 200, DEBUG otherwise (R5).
|
|
61
|
+
AUTH_HTTP_CODES = [401, 403, 404, 410].freeze
|
|
62
|
+
|
|
63
|
+
# @deprecated since 1.8.0. qfg-i5xv's "terminal" codes. Nothing is terminal
|
|
64
|
+
# any more (the latch is gone, qfg-goi1.10.26); kept as an alias of
|
|
65
|
+
# AUTH_HTTP_CODES for code that references the constant.
|
|
66
|
+
TERMINAL_HTTP_CODES = AUTH_HTTP_CODES
|
|
58
67
|
|
|
59
68
|
# qfg-6y44: headroom added to +sse_read_timeout+ when configuring
|
|
60
69
|
# Net::HTTP#read_timeout. The ReadDeadlineWatchdog already covers both the
|
|
@@ -69,13 +78,22 @@ module Quonfig
|
|
|
69
78
|
|
|
70
79
|
# +on_error+: optional callable invoked on every SSE error edge. Parent
|
|
71
80
|
# Quonfig::Client wires this to drive @sse_state -> :error so that
|
|
72
|
-
# +connection_state+ reflects the disconnect (qfg-47c2.27).
|
|
73
|
-
|
|
81
|
+
# +connection_state+ reflects the disconnect (qfg-47c2.27). A connection
|
|
82
|
+
# that ends before its first valid frame, clean EOF included, is an error
|
|
83
|
+
# edge too (qfg-goi1.10.43).
|
|
84
|
+
#
|
|
85
|
+
# +on_connected+: optional callable invoked once per connection, at its
|
|
86
|
+
# first valid frame (a keepalive, or a data block that validates as a
|
|
87
|
+
# config envelope), before that frame's envelope reaches +on_envelope+.
|
|
88
|
+
# Never on the 200 headers alone (supervisor-test-contract.md "Rule — a
|
|
89
|
+
# connection is `connected` at its first valid frame").
|
|
90
|
+
def initialize(prefab_options, config_loader, options = nil, logger = nil, on_error: nil, on_connected: nil)
|
|
74
91
|
@prefab_options = prefab_options
|
|
75
92
|
@options = options || Options.new
|
|
76
93
|
@config_loader = config_loader
|
|
77
94
|
@logger = logger || LOG
|
|
78
95
|
@on_error = on_error
|
|
96
|
+
@on_connected = on_connected
|
|
79
97
|
|
|
80
98
|
@stopped = Concurrent::AtomicBoolean.new(false)
|
|
81
99
|
@restart_total = 0
|
|
@@ -88,6 +106,8 @@ module Quonfig
|
|
|
88
106
|
@active_http = nil
|
|
89
107
|
|
|
90
108
|
@last_event_id = nil
|
|
109
|
+
# Auth-class statuses already logged at WARN since the last 200 (R5).
|
|
110
|
+
@auth_warned = Set.new
|
|
91
111
|
# qfg-7h5d.1.9 / qfg-41nh.6: latches true if a connection attempt ever
|
|
92
112
|
# targets a non-primary sse_api_urls leg. The failover epic asserts SSE
|
|
93
113
|
# does NOT fail over to the secondary (f05) — the stream is PINNED to
|
|
@@ -220,15 +240,23 @@ module Quonfig
|
|
|
220
240
|
begin
|
|
221
241
|
unless first_attempt
|
|
222
242
|
increment_restart!
|
|
223
|
-
interruptible_sleep(jittered(delay))
|
|
243
|
+
interruptible_sleep(jittered(Quonfig::Waits.duration(:sse, delay)))
|
|
224
244
|
break if @stopped.value
|
|
225
245
|
end
|
|
226
246
|
first_attempt = false
|
|
227
247
|
|
|
228
|
-
|
|
248
|
+
# First-frame rule (qfg-goi1.10.43): this connection counts as
|
|
249
|
+
# connected only at its first valid frame. Raised once, from the
|
|
250
|
+
# parser, before the frame's envelope is installed.
|
|
251
|
+
frame_seen = false
|
|
252
|
+
on_frame = lambda do
|
|
253
|
+
next if frame_seen
|
|
254
|
+
|
|
255
|
+
frame_seen = true
|
|
256
|
+
invoke_on_connected
|
|
257
|
+
end
|
|
229
258
|
begin
|
|
230
|
-
stream_once do |event|
|
|
231
|
-
connected_at_least_once = true
|
|
259
|
+
ended_url = stream_once(on_frame) do |event|
|
|
232
260
|
# Persist the most recent id so the next reconnect resumes
|
|
233
261
|
# from there via Last-Event-Id. Updated *before* the user
|
|
234
262
|
# callback runs so a raising listener still advances the
|
|
@@ -239,22 +267,30 @@ module Quonfig
|
|
|
239
267
|
# listener must not look like a transport error and trigger
|
|
240
268
|
# a reconnect.
|
|
241
269
|
invoke_on_envelope_safely(on_envelope, event)
|
|
242
|
-
# A connection healthy enough to deliver a real envelope
|
|
243
|
-
# earns a reset of the backoff. Sustained outages never
|
|
244
|
-
# reach this branch (no event ever delivered) so the
|
|
245
|
-
# exponential growth still holds.
|
|
246
|
-
delay = @options.sse_initial_reconnect_delay
|
|
247
270
|
end
|
|
271
|
+
# A clean EOF before any frame (200 headers then close) is a
|
|
272
|
+
# failed attempt, exactly like a connect error: report it so
|
|
273
|
+
# the client leaves :connected and arms the fallback poller.
|
|
274
|
+
# This is what bounds ruby's transparent reconnect: connected
|
|
275
|
+
# survives a clean-EOF reconnect only until the next connection
|
|
276
|
+
# ends frameless.
|
|
277
|
+
handle_frameless_end(ended_url) unless frame_seen || @stopped.value
|
|
248
278
|
rescue StandardError => e
|
|
249
279
|
handle_error(e) unless @stopped.value
|
|
250
280
|
end
|
|
251
281
|
|
|
252
|
-
#
|
|
253
|
-
#
|
|
254
|
-
# recycling)
|
|
255
|
-
#
|
|
256
|
-
#
|
|
257
|
-
|
|
282
|
+
# The backoff resets only for a connection that delivered a
|
|
283
|
+
# frame; it then reconnects at the initial delay even after a
|
|
284
|
+
# clean FIN (normal LB recycling) or an IO error later in the
|
|
285
|
+
# session. Every other attempt (connect error, non-200, or a
|
|
286
|
+
# frameless 200) doubles it. Sustained outages never deliver a
|
|
287
|
+
# frame, so the exponential growth still holds. Matches sdk-go's
|
|
288
|
+
# connectOnce "frame seen" result.
|
|
289
|
+
delay = if frame_seen
|
|
290
|
+
@options.sse_initial_reconnect_delay
|
|
291
|
+
else
|
|
292
|
+
[delay * 2, @options.sse_max_reconnect_delay].min
|
|
293
|
+
end
|
|
258
294
|
rescue SSEReadDeadlineExceeded => e
|
|
259
295
|
# Paranoid backstop (qfg-tj18). A watchdog raise that landed
|
|
260
296
|
# outside +stream_once+ — typically in +interruptible_sleep+
|
|
@@ -270,7 +306,8 @@ module Quonfig
|
|
|
270
306
|
|
|
271
307
|
# Opens one SSE request and yields each parsed event until the stream
|
|
272
308
|
# ends (clean FIN, error, or stop). Raises on transport errors so the
|
|
273
|
-
# caller can apply backoff. Clean FIN returns without raising.
|
|
309
|
+
# caller can apply backoff. Clean FIN returns the URL without raising.
|
|
310
|
+
# +on_frame+ is called at every valid frame (see EventParser#feed).
|
|
274
311
|
#
|
|
275
312
|
# A watchdog thread closes the socket if no bytes arrive within
|
|
276
313
|
# +sse_read_timeout+. Net::HTTP#read_timeout is NOT reliable for the
|
|
@@ -279,7 +316,7 @@ module Quonfig
|
|
|
279
316
|
# against a configured deadline). sdk-go and sdk-node hit the same
|
|
280
317
|
# gotcha and solve it the same way: per-chunk reset, async close on
|
|
281
318
|
# expiry (chaos scenario 02 — sse_silent_stall).
|
|
282
|
-
def stream_once(&block)
|
|
319
|
+
def stream_once(on_frame = nil, &block)
|
|
283
320
|
base = current_url
|
|
284
321
|
# f05 latch: record if this connection attempt targets a non-primary
|
|
285
322
|
# stream leg. With the pin in #current_url this is structurally false;
|
|
@@ -314,26 +351,7 @@ module Quonfig
|
|
|
314
351
|
|
|
315
352
|
begin
|
|
316
353
|
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
|
|
354
|
+
check_response_status!(resp.code.to_i, url)
|
|
337
355
|
|
|
338
356
|
parser = EventParser.new
|
|
339
357
|
# qfg-tj18: run_loop wraps the body in +:on_blocking+ which
|
|
@@ -343,9 +361,14 @@ module Quonfig
|
|
|
343
361
|
Thread.handle_interrupt(SSEReadDeadlineExceeded => :immediate) do
|
|
344
362
|
resp.read_body do |chunk|
|
|
345
363
|
watchdog.reset!
|
|
346
|
-
break
|
|
347
|
-
|
|
348
|
-
|
|
364
|
+
# Never +break+ here (qfg-yrly): a break returns into
|
|
365
|
+
# Net::HTTPResponse#reading_body, which then calls +body+ to
|
|
366
|
+
# drain the rest of the response with no block. On an endless
|
|
367
|
+
# SSE stream that blocks the worker forever. Raising skips the
|
|
368
|
+
# drain, and Net::HTTP closes the socket on the way out.
|
|
369
|
+
raise SSEStopRequested if @stopped.value
|
|
370
|
+
|
|
371
|
+
parser.feed(chunk, on_frame: on_frame, &block)
|
|
349
372
|
end
|
|
350
373
|
end
|
|
351
374
|
# read_body returned cleanly — either a server-initiated FIN, or
|
|
@@ -354,6 +377,9 @@ module Quonfig
|
|
|
354
377
|
# next iteration.
|
|
355
378
|
@logger.debug "SSE stream ended for url #{url}"
|
|
356
379
|
end
|
|
380
|
+
url
|
|
381
|
+
rescue SSEStopRequested
|
|
382
|
+
url
|
|
357
383
|
ensure
|
|
358
384
|
watchdog.stop
|
|
359
385
|
register_active(nil)
|
|
@@ -365,6 +391,35 @@ module Quonfig
|
|
|
365
391
|
end
|
|
366
392
|
end
|
|
367
393
|
|
|
394
|
+
# Classify the stream response. Raises for any non-200 so run_loop applies
|
|
395
|
+
# its backoff.
|
|
396
|
+
#
|
|
397
|
+
# Auth class (401/403/404/410, qfg-goi1.10.26): no latch. The normal
|
|
398
|
+
# ladder applies and run_loop's handle_error fires on_error once, so the
|
|
399
|
+
# client schedules the fallback poller exactly as for any drop; that
|
|
400
|
+
# poller's fetch cycle is what decides auth-failed (R2). Logged WARN once
|
|
401
|
+
# per status until a 200 re-arms it, DEBUG otherwise (R5), so a revoked
|
|
402
|
+
# key does not log two ERRORs per attempt for the ~2 min before entry.
|
|
403
|
+
def check_response_status!(code, url)
|
|
404
|
+
if AUTH_HTTP_CODES.include?(code)
|
|
405
|
+
if @auth_warned.add?(code)
|
|
406
|
+
@logger.warn "SSE Streaming Error: HTTP #{code} for url #{url}; " \
|
|
407
|
+
'the SDK key may be invalid or revoked (check the SDK key); retrying'
|
|
408
|
+
else
|
|
409
|
+
@logger.debug "SSE Streaming Error: HTTP #{code} for url #{url}; retrying"
|
|
410
|
+
end
|
|
411
|
+
raise SSEHTTPAuthError, code
|
|
412
|
+
end
|
|
413
|
+
if code != 200
|
|
414
|
+
err = SSEHTTPStatusError.new(code)
|
|
415
|
+
@logger.error "SSE Streaming Error: HTTP #{code} for url #{url}"
|
|
416
|
+
invoke_on_error(err)
|
|
417
|
+
raise err
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
@auth_warned.clear
|
|
421
|
+
end
|
|
422
|
+
|
|
368
423
|
# Track the active connection so close() can interrupt a blocked
|
|
369
424
|
# read_body from another thread. Guarded by @conn_mutex.
|
|
370
425
|
def register_active(http)
|
|
@@ -375,8 +430,32 @@ module Quonfig
|
|
|
375
430
|
@restart_mutex.synchronize { @restart_total += 1 }
|
|
376
431
|
end
|
|
377
432
|
|
|
433
|
+
# A connection that returned 200 and ended cleanly before its first valid
|
|
434
|
+
# frame (qfg-goi1.10.43).
|
|
435
|
+
def handle_frameless_end(url)
|
|
436
|
+
err = SSEFramelessSessionError.new
|
|
437
|
+
@logger.warn "SSE Streaming Error: stream for url #{url} ended before its first frame; " \
|
|
438
|
+
'treating it as a failed attempt'
|
|
439
|
+
invoke_on_error(err)
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def invoke_on_connected
|
|
443
|
+
return unless @on_connected
|
|
444
|
+
|
|
445
|
+
begin
|
|
446
|
+
@on_connected.call
|
|
447
|
+
rescue StandardError => e
|
|
448
|
+
@logger.error "SSE on_connected callback raised: #{e.inspect}"
|
|
449
|
+
end
|
|
450
|
+
end
|
|
451
|
+
|
|
378
452
|
def handle_error(error)
|
|
379
|
-
|
|
453
|
+
if error.is_a?(SSEHTTPAuthError)
|
|
454
|
+
# Already logged once by check_response_status! (R5).
|
|
455
|
+
@logger.debug "SSE Streaming Error: #{error.inspect}"
|
|
456
|
+
else
|
|
457
|
+
@logger.error "SSE Streaming Error: #{error.inspect}"
|
|
458
|
+
end
|
|
380
459
|
invoke_on_error(error)
|
|
381
460
|
end
|
|
382
461
|
|
|
@@ -445,19 +524,34 @@ module Quonfig
|
|
|
445
524
|
end
|
|
446
525
|
end
|
|
447
526
|
|
|
448
|
-
#
|
|
449
|
-
#
|
|
450
|
-
#
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
#
|
|
454
|
-
|
|
527
|
+
# An auth-class (401/403/404/410) stream response (qfg-goi1.10.26). A
|
|
528
|
+
# subclass of SSEHTTPStatusError so on_error callbacks that only check
|
|
529
|
+
# `is_a?(SSEHTTPStatusError)` keep working. The stream keeps retrying.
|
|
530
|
+
class SSEHTTPAuthError < SSEHTTPStatusError; end
|
|
531
|
+
|
|
532
|
+
# @deprecated since 1.8.0: qfg-i5xv's name for SSEHTTPAuthError. The stream
|
|
533
|
+
# no longer stops on these statuses.
|
|
534
|
+
SSEHTTPTerminalError = SSEHTTPAuthError # rubocop:disable Lint/UselessConstantScoping
|
|
535
|
+
|
|
536
|
+
# Passed to on_error when a 200 stream ends cleanly before its first valid
|
|
537
|
+
# frame (qfg-goi1.10.43). Not raised.
|
|
538
|
+
class SSEFramelessSessionError < StandardError
|
|
539
|
+
def initialize(msg = 'SSE stream ended before its first frame')
|
|
540
|
+
super
|
|
541
|
+
end
|
|
542
|
+
end
|
|
455
543
|
|
|
456
544
|
# Raised by the watchdog into the worker thread when the per-chunk
|
|
457
545
|
# read deadline elapses. Caught by run_loop's rescue, indistinguishable
|
|
458
546
|
# from any other transport error for backoff/restart purposes.
|
|
459
547
|
class SSEReadDeadlineExceeded < StandardError; end
|
|
460
548
|
|
|
549
|
+
# Private: raised from inside +read_body+ when +close+ has been called,
|
|
550
|
+
# so the worker leaves the stream without Net::HTTP draining the body
|
|
551
|
+
# (qfg-yrly). Rescued in +stream_once+; never reaches callers.
|
|
552
|
+
class SSEStopRequested < StandardError; end
|
|
553
|
+
private_constant :SSEStopRequested
|
|
554
|
+
|
|
461
555
|
# Background watchdog that interrupts the worker thread if no chunk
|
|
462
556
|
# arrives within +deadline_s+ seconds. Uses Thread#raise — the only
|
|
463
557
|
# reliable cross-platform way to unblock a Ruby thread blocked in
|
|
@@ -535,7 +629,7 @@ module Quonfig
|
|
|
535
629
|
# - chunks that split a line mid-way
|
|
536
630
|
# - any of CR / LF / CRLF as line terminators
|
|
537
631
|
# - +data:+, +data: + (optional space per SSE spec)
|
|
538
|
-
# - +:comment+ lines (keepalives
|
|
632
|
+
# - +:comment+ lines (keepalives; a comment-only block is a frame)
|
|
539
633
|
# - multi-line +data:+ (concatenated with +\n+, per spec)
|
|
540
634
|
# Ignores +event:+ and +retry:+ — api-delivery does not emit them and the
|
|
541
635
|
# Quonfig wire contract does not honor reconnect-time directives.
|
|
@@ -548,16 +642,34 @@ module Quonfig
|
|
|
548
642
|
@data = +''
|
|
549
643
|
@have_data = false
|
|
550
644
|
@id = nil
|
|
645
|
+
@saw_comment = false
|
|
646
|
+
@saw_field = false
|
|
551
647
|
end
|
|
552
648
|
|
|
553
|
-
|
|
649
|
+
# Yields each config envelope. +on_frame+ (optional) is called at every
|
|
650
|
+
# valid frame, before that frame's envelope is yielded. A valid frame is
|
|
651
|
+
# a block ended by a blank line that is either comment-only (the
|
|
652
|
+
# +: keepalive+ heartbeat; +:+ alone counts) or carries +data:+ that
|
|
653
|
+
# decodes and validates as a config envelope. A block cut off by EOF, a
|
|
654
|
+
# malformed or non-envelope data block, an +id:+-only block and a bare
|
|
655
|
+
# blank line are not frames (qfg-goi1.10.43).
|
|
656
|
+
def feed(chunk, on_frame: nil)
|
|
554
657
|
@reader.feed(chunk) do |line|
|
|
555
658
|
if line.empty?
|
|
659
|
+
comment_only = @saw_comment && !@saw_field
|
|
660
|
+
@saw_comment = false
|
|
661
|
+
@saw_field = false
|
|
556
662
|
event = flush
|
|
557
|
-
|
|
663
|
+
if event
|
|
664
|
+
on_frame&.call
|
|
665
|
+
yield event
|
|
666
|
+
elsif comment_only
|
|
667
|
+
on_frame&.call
|
|
668
|
+
end
|
|
558
669
|
elsif line.start_with?(':')
|
|
559
|
-
|
|
670
|
+
@saw_comment = true
|
|
560
671
|
else
|
|
672
|
+
@saw_field = true
|
|
561
673
|
process_field(line)
|
|
562
674
|
end
|
|
563
675
|
end
|
|
@@ -612,10 +724,18 @@ module Quonfig
|
|
|
612
724
|
return nil
|
|
613
725
|
end
|
|
614
726
|
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
727
|
+
meta = parsed['meta'] || {}
|
|
728
|
+
# Skip one malformed config, keep the rest (qfg-krjt); an event with
|
|
729
|
+
# no loadable config is dropped like malformed JSON.
|
|
730
|
+
begin
|
|
731
|
+
configs = Quonfig::ConfigShape.filter(parsed['configs'] || [], logger: @logger || LOG,
|
|
732
|
+
version: meta['version'])
|
|
733
|
+
rescue Quonfig::ConfigShape::AllInvalidError => e
|
|
734
|
+
(@logger || LOG).error "SSE Streaming Error: #{e.message}; event dropped"
|
|
735
|
+
return nil
|
|
736
|
+
end
|
|
737
|
+
|
|
738
|
+
envelope = Quonfig::ConfigEnvelope.new(configs: configs, meta: meta)
|
|
619
739
|
StreamEvent.new(envelope, id, data)
|
|
620
740
|
end
|
|
621
741
|
end
|
|
@@ -630,37 +750,59 @@ module Quonfig
|
|
|
630
750
|
def initialize
|
|
631
751
|
@buffer = +''.b
|
|
632
752
|
@last_was_cr = false
|
|
753
|
+
# Bytes of @buffer already scanned without finding a terminator, so
|
|
754
|
+
# the next feed resumes there instead of rescanning from byte 0
|
|
755
|
+
# (qfg-goi1.10.2; O(n^2) on a long line in small chunks).
|
|
756
|
+
@scanned = 0
|
|
633
757
|
end
|
|
634
758
|
|
|
759
|
+
# Lines are sliced out by position and the consumed prefix is dropped
|
|
760
|
+
# once per feed (in +ensure+, so a raising block does not see a line
|
|
761
|
+
# twice), not once per line.
|
|
635
762
|
def feed(chunk)
|
|
636
763
|
@buffer << chunk.b
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
764
|
+
pos = 0
|
|
765
|
+
begin
|
|
766
|
+
loop do
|
|
767
|
+
idx = @buffer.index(/[\r\n]/, [pos, @scanned].max)
|
|
768
|
+
if idx.nil?
|
|
769
|
+
@scanned = @buffer.bytesize
|
|
770
|
+
break
|
|
771
|
+
end
|
|
640
772
|
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
773
|
+
ch = @buffer.getbyte(idx)
|
|
774
|
+
if idx == pos && ch == LF && @last_was_cr
|
|
775
|
+
# Dangling LF of a CRLF pair split across chunks — consume and skip.
|
|
776
|
+
@last_was_cr = false
|
|
777
|
+
pos += 1
|
|
778
|
+
next
|
|
779
|
+
end
|
|
648
780
|
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
781
|
+
line = @buffer.byteslice(pos, idx - pos).force_encoding('UTF-8')
|
|
782
|
+
consume_to = idx + 1
|
|
783
|
+
@last_was_cr = false
|
|
784
|
+
if ch == CR
|
|
785
|
+
if consume_to == @buffer.bytesize
|
|
786
|
+
# CR at end of buffer — could be CRLF split across feeds.
|
|
787
|
+
@last_was_cr = true
|
|
788
|
+
elsif @buffer.getbyte(consume_to) == LF
|
|
789
|
+
consume_to += 1
|
|
790
|
+
end
|
|
658
791
|
end
|
|
792
|
+
pos = consume_to
|
|
793
|
+
yield line
|
|
794
|
+
end
|
|
795
|
+
ensure
|
|
796
|
+
if pos.positive?
|
|
797
|
+
@buffer = @buffer.byteslice(pos, @buffer.bytesize - pos)
|
|
798
|
+
@scanned = [@scanned - pos, 0].max
|
|
659
799
|
end
|
|
660
|
-
@buffer.slice!(0, consume)
|
|
661
|
-
yield line
|
|
662
800
|
end
|
|
663
801
|
end
|
|
802
|
+
|
|
803
|
+
CR = 13
|
|
804
|
+
LF = 10
|
|
805
|
+
private_constant :CR, :LF
|
|
664
806
|
end
|
|
665
807
|
end
|
|
666
808
|
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
|
|
@@ -43,7 +43,9 @@ module Quonfig
|
|
|
43
43
|
private
|
|
44
44
|
|
|
45
45
|
def weight_of(variant)
|
|
46
|
-
|
|
46
|
+
# A missing weight counts as 0 (qfg-krjt); a present one is checked to
|
|
47
|
+
# be an Integer at decode (ConfigShape).
|
|
48
|
+
variant[:weight] || variant['weight'] || 0
|
|
47
49
|
end
|
|
48
50
|
end
|
|
49
51
|
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'
|
|
@@ -49,6 +50,7 @@ require 'quonfig/config_store'
|
|
|
49
50
|
require 'quonfig/evaluator'
|
|
50
51
|
require 'quonfig/resolver'
|
|
51
52
|
require 'quonfig/config_envelope'
|
|
53
|
+
require 'quonfig/config_shape'
|
|
52
54
|
require 'quonfig/config_loader'
|
|
53
55
|
require 'quonfig/datadir'
|
|
54
56
|
require 'listen'
|