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.
@@ -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 successful event delivery resets the delay to the initial
38
- # value — matches sdk-python's policy. A clean server-initiated FIN is
39
- # treated as "not a failure for backoff purposes" because LBs recycling
40
- # connections is normal; the reconnect counter still increments.
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
- # qfg-i5xv: HTTP status codes the SDK classifies as terminal — these will
55
- # not heal by retrying (bad key, revoked permission, missing endpoint).
56
- # Anything else (5xx, 429, network errors) stays on the transient path.
57
- TERMINAL_HTTP_CODES = [401, 403, 404].freeze
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
- def initialize(prefab_options, config_loader, options = nil, logger = nil, on_error: nil)
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
- connected_at_least_once = false
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
- # Backoff only grows on failed connect attempts. A server-
253
- # initiated clean FIN after a healthy session (normal LB
254
- # recycling) reuses the same delay — punishing it would make
255
- # us look broken under benign rolling restarts. Matches
256
- # sdk-go's `connectedOK` distinction.
257
- delay = [delay * 2, @options.sse_max_reconnect_delay].min unless connected_at_least_once
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
- code = resp.code.to_i
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 if @stopped.value
347
-
348
- parser.feed(chunk, &block)
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
- @logger.error "SSE Streaming Error: #{error.inspect}"
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
- # qfg-i5xv: terminal HTTP failures the SDK will not retry. 401 = bad key,
449
- # 403 = revoked workspace permission, 404 = wrong endpoint / missing
450
- # workspace. A subclass of SSEHTTPStatusError so existing on_error
451
- # callbacks that only check `is_a?(SSEHTTPStatusError)` keep working,
452
- # while customers that want to distinguish (alerting, OpenFeature
453
- # provider error events) can dispatch on the subclass.
454
- class SSEHTTPTerminalError < SSEHTTPStatusError; end
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 — ignored)
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
- def feed(chunk)
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
- yield event if event
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
- # comment / keepalive — ignore
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
- envelope = Quonfig::ConfigEnvelope.new(
616
- configs: parsed['configs'] || [],
617
- meta: parsed['meta'] || {}
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
- loop do
638
- idx = @buffer.index(/[\r\n]/)
639
- break if idx.nil?
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
- ch = @buffer[idx]
642
- if idx.zero? && ch == "\n" && @last_was_cr
643
- # Dangling LF of a CRLF pair split across chunks — consume and skip.
644
- @last_was_cr = false
645
- @buffer.slice!(0, 1)
646
- next
647
- end
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
- line = @buffer[0, idx].force_encoding('UTF-8')
650
- consume = idx + 1
651
- @last_was_cr = false
652
- if ch == "\r"
653
- if consume == @buffer.bytesize
654
- # CR at end of buffer — could be CRLF split across feeds.
655
- @last_was_cr = true
656
- elsif @buffer[consume] == "\n"
657
- consume += 1
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 client.should_log?(logger_path: path, desired_level: level)
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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Quonfig
4
- VERSION = '1.7.2'
4
+ VERSION = '1.9.0'
5
5
  end
@@ -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
- variant[:weight] || variant['weight']
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'