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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +14 -0
- data/README.md +55 -11
- data/lib/quonfig/client.rb +398 -22
- data/lib/quonfig/datadir.rb +8 -3
- data/lib/quonfig/sse_config_client.rb +550 -93
- data/lib/quonfig/version.rb +1 -1
- data/lib/quonfig/worker_supervisor.rb +186 -0
- data/lib/quonfig.rb +2 -1
- data/quonfig.gemspec +0 -1
- metadata +3 -16
data/lib/quonfig/version.rb
CHANGED
|
@@ -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 '
|
|
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
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.
|
|
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-
|
|
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:
|