quonfig 0.0.14 → 0.0.16

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.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Quonfig
4
- VERSION = '0.0.14'
4
+ VERSION = '0.0.16'
5
5
  end
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quonfig
4
+ # Internal control-flow exception raised inside a supervised worker thread
5
+ # to signal cooperative shutdown. Workers may catch and re-raise, or just
6
+ # propagate.
7
+ class Shutdown < StandardError; end
8
+
9
+ # Single supervisor for a long-lived background worker (SSE read loop,
10
+ # fallback poller). Catches unhandled exceptions at the worker boundary,
11
+ # logs them, increments +worker_restart_total+, and restarts with
12
+ # exponential backoff capped at 30s.
13
+ #
14
+ # Contract: integration-test-data/chaos/supervisor-test-contract.md
15
+ # Plan: project/plans/sdk-hardening-and-verification.md (Phase 1)
16
+ #
17
+ # The worker is a Proc-like callable invoked as +worker.call(notify_delivered)+
18
+ # where +notify_delivered+ is a Proc the worker calls when it has handed at
19
+ # least one envelope to the cache. That signal resets the backoff so a
20
+ # transient blip doesn't double the delay on the next disconnect.
21
+ #
22
+ # Shutdown is signaled by Thread#raise(Quonfig::Shutdown) into the
23
+ # supervisor thread. Logger writes and bookkeeping use Thread.handle_interrupt
24
+ # so a concurrent raise doesn't trip Ruby's "log writing failed" path.
25
+ class WorkerSupervisor
26
+ METRIC_NAME = 'quonfig_sdk_worker_restart_total'
27
+
28
+ DEFAULT_INITIAL_BACKOFF = 0.5
29
+ DEFAULT_MAX_BACKOFF = 30.0
30
+ DEFAULT_MULTIPLIER = 2.0
31
+ SHUTDOWN_TIMEOUT_SEC = 5.0
32
+
33
+ LOG = Quonfig::InternalLogger.new(self)
34
+
35
+ attr_reader :worker_restart_total, :worker_restart_labels
36
+
37
+ def initialize(name:, worker:, layer: '1',
38
+ initial_backoff: DEFAULT_INITIAL_BACKOFF,
39
+ max_backoff: DEFAULT_MAX_BACKOFF,
40
+ multiplier: DEFAULT_MULTIPLIER,
41
+ sleep_proc: nil,
42
+ logger: nil)
43
+ @name = name
44
+ @layer = layer.to_s
45
+ @worker = worker
46
+ @initial_backoff = initial_backoff
47
+ @max_backoff = max_backoff
48
+ @multiplier = multiplier
49
+ @sleep_proc = sleep_proc || ->(seconds) { sleep(seconds) }
50
+ @logger = logger || LOG
51
+ @worker_restart_total = 0
52
+ @worker_restart_labels = {
53
+ sdk: 'ruby',
54
+ sdk_version: Quonfig::VERSION,
55
+ layer: @layer
56
+ }.freeze
57
+ @mutex = Mutex.new
58
+ @stop_requested = false
59
+ @thread = nil
60
+ @current_backoff = @initial_backoff
61
+ end
62
+
63
+ def start
64
+ @mutex.synchronize do
65
+ return self if @thread&.alive?
66
+
67
+ @stop_requested = false
68
+ ready = Queue.new
69
+ @thread = Thread.new do
70
+ # Set report_on_exception + signal "ready" BEFORE entering
71
+ # run_loop. start() blocks on the ready queue so a racing stop()
72
+ # can never raise into a thread that hasn't yet installed its
73
+ # Shutdown rescue.
74
+ Thread.current.report_on_exception = false
75
+ ready << true
76
+ run_loop
77
+ rescue Quonfig::Shutdown
78
+ # cooperative shutdown raced with thread startup; swallowed
79
+ end
80
+ ready.pop
81
+ end
82
+ self
83
+ end
84
+
85
+ def alive?
86
+ t = @thread
87
+ !t.nil? && t.alive?
88
+ end
89
+
90
+ def stop
91
+ thread = @mutex.synchronize do
92
+ @stop_requested = true
93
+ t = @thread
94
+ @thread = nil
95
+ t
96
+ end
97
+ return if thread.nil?
98
+
99
+ raise_shutdown(thread)
100
+ thread.join(SHUTDOWN_TIMEOUT_SEC)
101
+ thread.kill if thread.alive?
102
+ nil
103
+ end
104
+
105
+ alias close stop
106
+
107
+ private
108
+
109
+ def raise_shutdown(thread)
110
+ return if thread.nil?
111
+ return unless thread.alive?
112
+
113
+ begin
114
+ thread.raise(Quonfig::Shutdown.new('supervisor stopping'))
115
+ rescue ThreadError
116
+ # thread already exited between alive? and raise — fine
117
+ end
118
+ end
119
+
120
+ def run_loop
121
+ Thread.current.name = "quonfig-supervisor-#{@name}"
122
+ # Don't dump our managed Shutdown to stderr on shutdown.
123
+ Thread.current.report_on_exception = false
124
+
125
+ loop do
126
+ break if stop?
127
+
128
+ delivered = false
129
+ notify_delivered = -> { delivered = true }
130
+ reason = :worker_exit
131
+
132
+ begin
133
+ @worker.call(notify_delivered)
134
+ rescue Quonfig::Shutdown
135
+ break
136
+ rescue StandardError => e
137
+ reason = :worker_throw
138
+ safe_log(:error,
139
+ "[quonfig] supervisor=#{@name} worker raised #{e.class}: #{e.message}")
140
+ bt = e.backtrace&.first(10)&.join("\n")
141
+ safe_log(:debug, bt) if bt
142
+ end
143
+
144
+ break if stop?
145
+
146
+ @worker_restart_total += 1
147
+ @current_backoff = @initial_backoff if delivered
148
+ backoff = @current_backoff
149
+
150
+ safe_log(:warn,
151
+ "[quonfig] supervisor=#{@name} restarting worker " \
152
+ "(reason=#{reason}, restart_total=#{@worker_restart_total}, " \
153
+ "backoff_s=#{backoff})")
154
+
155
+ begin
156
+ @sleep_proc.call(backoff)
157
+ rescue Quonfig::Shutdown
158
+ break
159
+ end
160
+
161
+ @current_backoff = [@current_backoff * @multiplier, @max_backoff].min
162
+ end
163
+ rescue Quonfig::Shutdown
164
+ # supervisor-level cooperative shutdown
165
+ rescue StandardError => e
166
+ safe_log(:error, "[quonfig] supervisor=#{@name} crashed: #{e.class}: #{e.message}")
167
+ end
168
+
169
+ def stop?
170
+ @mutex.synchronize { @stop_requested }
171
+ end
172
+
173
+ # Defer Shutdown delivery while we're inside Logger.write so we don't
174
+ # trip Logger's "log writing failed" -> stderr fallback. Swallow any
175
+ # other logger error.
176
+ def safe_log(level, msg)
177
+ return unless @logger.respond_to?(level)
178
+
179
+ Thread.handle_interrupt(Quonfig::Shutdown => :never) do
180
+ @logger.public_send(level, msg)
181
+ end
182
+ rescue StandardError
183
+ nil
184
+ end
185
+ end
186
+ end
data/lib/quonfig.rb CHANGED
@@ -17,7 +17,7 @@ require 'concurrent/atomics'
17
17
  require 'concurrent'
18
18
  require 'faraday'
19
19
  require 'openssl'
20
- require 'ld-eventsource'
20
+ require 'net/http'
21
21
 
22
22
  require 'quonfig/internal_logger'
23
23
  require 'quonfig/time_helpers'
@@ -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/worker_supervisor'
32
33
  require 'quonfig/periodic_sync'
33
34
  require 'quonfig/errors/initialization_timeout_error'
34
35
  require 'quonfig/errors/invalid_sdk_key_error'
data/quonfig.gemspec CHANGED
@@ -31,5 +31,4 @@ Gem::Specification.new do |s|
31
31
  s.add_dependency 'activesupport', '>= 4'
32
32
  s.add_dependency 'concurrent-ruby', '~> 1.0', '>= 1.0.5'
33
33
  s.add_dependency 'faraday', '>= 1.0'
34
- s.add_dependency 'ld-eventsource', '>= 2.0'
35
34
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: quonfig
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.14
4
+ version: 0.0.16
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jeff Dwyer
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-10 00:00:00.000000000 Z
11
+ date: 2026-05-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -58,20 +58,6 @@ dependencies:
58
58
  - - ">="
59
59
  - !ruby/object:Gem::Version
60
60
  version: '1.0'
61
- - !ruby/object:Gem::Dependency
62
- name: ld-eventsource
63
- requirement: !ruby/object:Gem::Requirement
64
- requirements:
65
- - - ">="
66
- - !ruby/object:Gem::Version
67
- version: '2.0'
68
- type: :runtime
69
- prerelease: false
70
- version_requirements: !ruby/object:Gem::Requirement
71
- requirements:
72
- - - ">="
73
- - !ruby/object:Gem::Version
74
- version: '2.0'
75
61
  description: Quonfig — feature flags and live config, stored as files in git.
76
62
  email: jeff@quonfig.com
77
63
  executables: []
@@ -134,6 +120,7 @@ files:
134
120
  - lib/quonfig/types.rb
135
121
  - lib/quonfig/version.rb
136
122
  - lib/quonfig/weighted_value_resolver.rb
123
+ - lib/quonfig/worker_supervisor.rb
137
124
  - quonfig.gemspec
138
125
  homepage: https://github.com/quonfig/sdk-ruby
139
126
  licenses: