star_gates 0.0.0 → 0.2.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/.dictate.toml +19 -0
- data/.pre-commit-config.yaml +20 -0
- data/CHANGELOG.md +60 -3
- data/LICENSE.txt +23 -16
- data/README.md +182 -16
- data/Rakefile +14 -4
- data/lib/star_gates/adapters/base_adapter.rb +65 -0
- data/lib/star_gates/adapters/memory_adapter.rb +93 -0
- data/lib/star_gates/adapters/redis_adapter.rb +119 -0
- data/lib/star_gates/application_star_gate.rb +398 -0
- data/lib/star_gates/circuit.rb +171 -0
- data/lib/star_gates/configuration.rb +172 -0
- data/lib/star_gates/dsl.rb +71 -2
- data/lib/star_gates/errors.rb +8 -0
- data/lib/star_gates/event_store/event_store.rb +79 -0
- data/lib/star_gates/event_store/memory_adapter.rb +78 -0
- data/lib/star_gates/events/base_event.rb +45 -0
- data/lib/star_gates/events/feature_events.rb +97 -0
- data/lib/star_gates/feature.rb +426 -0
- data/lib/star_gates/flags.rb +153 -0
- data/lib/star_gates/gate.rb +100 -0
- data/lib/star_gates/hexagonal/feature_aggregate.rb +250 -0
- data/lib/star_gates/hexagonal/feature_repository.rb +67 -0
- data/lib/star_gates/hexagonal/feature_service.rb +121 -0
- data/lib/star_gates/jsonrpc_rails_adapter.rb +251 -0
- data/lib/star_gates/projections/feature_projection.rb +224 -0
- data/lib/star_gates/registry.rb +58 -0
- data/lib/star_gates/telemetry/opentelemetry_adapter.rb +252 -0
- data/lib/star_gates/telemetry/prometheus_adapter.rb +183 -0
- data/lib/star_gates/telemetry/telemetry_adapter.rb +160 -0
- data/lib/star_gates/version.rb +1 -1
- data/lib/star_gates.rb +308 -8
- metadata +95 -14
- data/sig/star_gates.rbs +0 -4
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base_adapter'
|
|
4
|
+
|
|
5
|
+
module StarGates
|
|
6
|
+
module Adapters
|
|
7
|
+
class RedisAdapter < BaseAdapter
|
|
8
|
+
def initialize(config = {})
|
|
9
|
+
super
|
|
10
|
+
@redis = config[:redis] || redis_from_config
|
|
11
|
+
@key_prefix = config[:key_prefix] || 'star_gates'
|
|
12
|
+
@default_ttl = config[:default_ttl] || 3600 # 1 hour
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def store(key, value)
|
|
16
|
+
serialized = serialize(value)
|
|
17
|
+
@redis.set(prefixed_key(key), serialized)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def fetch(key)
|
|
21
|
+
value = @redis.get(prefixed_key(key))
|
|
22
|
+
value ? deserialize(value) : nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def delete(key)
|
|
26
|
+
@redis.del(prefixed_key(key))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def exists?(key)
|
|
30
|
+
@redis.exists?(prefixed_key(key)).positive?
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def cache_fetch(key, expires_in: nil, &block)
|
|
34
|
+
prefixed = prefixed_key(key)
|
|
35
|
+
cached = @redis.get(prefixed)
|
|
36
|
+
|
|
37
|
+
if cached
|
|
38
|
+
deserialize(cached)
|
|
39
|
+
elsif block_given?
|
|
40
|
+
value = block.call
|
|
41
|
+
if value
|
|
42
|
+
serialized = serialize(value)
|
|
43
|
+
ttl = expires_in || @default_ttl
|
|
44
|
+
@redis.setex(prefixed, ttl, serialized)
|
|
45
|
+
end
|
|
46
|
+
value
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def cache_write(key, value, expires_in: nil)
|
|
51
|
+
serialized = serialize(value)
|
|
52
|
+
ttl = expires_in || @default_ttl
|
|
53
|
+
@redis.setex(prefixed_key(key), ttl, serialized)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def cache_delete(key)
|
|
57
|
+
@redis.del(prefixed_key(key))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def publish(topic, message)
|
|
61
|
+
@redis.publish(topic_key(topic), serialize(message))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def subscribe(topic, &block)
|
|
65
|
+
# Redis pub/sub requires a dedicated connection
|
|
66
|
+
# This is a simplified version - in production, use a separate subscriber thread
|
|
67
|
+
@redis.subscribe(topic_key(topic)) do |on|
|
|
68
|
+
on.message do |_channel, message|
|
|
69
|
+
block.call(deserialize(message))
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def healthy?
|
|
75
|
+
@redis.ping == 'PONG'
|
|
76
|
+
rescue StandardError
|
|
77
|
+
false
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def prefixed_key(key)
|
|
83
|
+
"#{@key_prefix}:#{key}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def topic_key(topic)
|
|
87
|
+
"#{@key_prefix}:topics:#{topic}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def serialize(value)
|
|
91
|
+
case value
|
|
92
|
+
when String
|
|
93
|
+
value
|
|
94
|
+
when Symbol
|
|
95
|
+
value.to_s
|
|
96
|
+
else
|
|
97
|
+
JSON.generate(value)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def deserialize(value)
|
|
102
|
+
JSON.parse(value)
|
|
103
|
+
rescue JSON::ParserError
|
|
104
|
+
value
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def redis_from_config
|
|
108
|
+
raise ArgumentError, "Redis gem not available. Install with: gem 'redis'" unless defined?(Redis)
|
|
109
|
+
|
|
110
|
+
# Try to use existing Redis configuration
|
|
111
|
+
if defined?(Rails) && Rails.application.config.respond_to?(:redis)
|
|
112
|
+
Redis.new(Rails.application.config.redis)
|
|
113
|
+
else
|
|
114
|
+
Redis.new
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/configurable'
|
|
4
|
+
require 'active_support/core_ext/class/attribute'
|
|
5
|
+
require 'active_support/core_ext/hash/deep_merge'
|
|
6
|
+
require 'active_support/core_ext/hash/indifferent_access'
|
|
7
|
+
require 'active_support/concern'
|
|
8
|
+
require 'active_support/notifications'
|
|
9
|
+
require_relative 'configuration'
|
|
10
|
+
|
|
11
|
+
module StarGates
|
|
12
|
+
class ApplicationStarGate < Feature
|
|
13
|
+
include ActiveSupport::Configurable
|
|
14
|
+
|
|
15
|
+
# Use ActiveSupport class attributes instead of custom implementation
|
|
16
|
+
class_attribute :feature_name
|
|
17
|
+
class_attribute :telemetry_tags, default: {}
|
|
18
|
+
class_attribute :state_configs, default: {}
|
|
19
|
+
|
|
20
|
+
def initialize(rollout: nil, group: nil, **options)
|
|
21
|
+
# Use ActiveSupport::HashWithIndifferentAccess for better key handling
|
|
22
|
+
@instance_config = ActiveSupport::HashWithIndifferentAccess.new(options)
|
|
23
|
+
|
|
24
|
+
# Merge with class-level config using deep_merge
|
|
25
|
+
@merged_config = self.class.config.to_h.with_indifferent_access.deep_merge(@instance_config)
|
|
26
|
+
|
|
27
|
+
# Use configuration defaults if not provided
|
|
28
|
+
rollout ||= @merged_config[:default_rollout] || 0
|
|
29
|
+
group ||= @merged_config[:default_group]
|
|
30
|
+
|
|
31
|
+
super(rollout: rollout, group: group)
|
|
32
|
+
|
|
33
|
+
# Initialize state-specific configurations using HashWithIndifferentAccess
|
|
34
|
+
@feature_config = ActiveSupport::HashWithIndifferentAccess.new(state_configs[:enabled] || {})
|
|
35
|
+
@default_config = ActiveSupport::HashWithIndifferentAccess.new(state_configs[:disabled] || {})
|
|
36
|
+
@canary_config = ActiveSupport::HashWithIndifferentAccess.new(state_configs[:canary] || {})
|
|
37
|
+
|
|
38
|
+
# Setup telemetry if enabled
|
|
39
|
+
setup_telemetry if telemetry_enabled?
|
|
40
|
+
|
|
41
|
+
# Setup error handling
|
|
42
|
+
setup_error_handling if error_reporting_enabled?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Enhanced state-specific methods with instrumentation and telemetry
|
|
46
|
+
def execute(context = {}, &block)
|
|
47
|
+
instrument_execution(context) do
|
|
48
|
+
super(context) do
|
|
49
|
+
if block_given?
|
|
50
|
+
with_timeout(@merged_config[:timeout], &block)
|
|
51
|
+
else
|
|
52
|
+
true
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def ui_component(context = {})
|
|
59
|
+
instrument_ui_access(context) do
|
|
60
|
+
super(context)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def config_value(key, context = {})
|
|
65
|
+
instrument_config_access(key, context) do
|
|
66
|
+
super(key, context)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Configuration access using ActiveSupport::Configurable
|
|
71
|
+
def timeout
|
|
72
|
+
@merged_config[:timeout]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def retry_count
|
|
76
|
+
@merged_config[:retry_count]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def circuit_breaker_threshold
|
|
80
|
+
@merged_config[:circuit_breaker_threshold]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def cache_ttl
|
|
84
|
+
@merged_config[:cache_ttl]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Public method for checking performance monitoring status
|
|
88
|
+
def performance_monitoring_enabled?
|
|
89
|
+
@merged_config[:performance_monitoring]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Public method for configuration summary
|
|
93
|
+
def configuration_summary
|
|
94
|
+
{
|
|
95
|
+
timeout: timeout,
|
|
96
|
+
retry_count: retry_count,
|
|
97
|
+
circuit_breaker_threshold: circuit_breaker_threshold,
|
|
98
|
+
cache_ttl: cache_ttl,
|
|
99
|
+
telemetry_enabled: telemetry_enabled?,
|
|
100
|
+
performance_monitoring: performance_monitoring_enabled?,
|
|
101
|
+
error_reporting: error_reporting_enabled?
|
|
102
|
+
}
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Public method to get feature identifier
|
|
106
|
+
def feature_identifier
|
|
107
|
+
self.class.feature_name || self.class.name.demodulize.underscore
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Feature-specific configuration with state awareness
|
|
111
|
+
def feature_config
|
|
112
|
+
case status
|
|
113
|
+
when 'enabled'
|
|
114
|
+
@feature_config
|
|
115
|
+
when 'canary'
|
|
116
|
+
@canary_config.any? ? @canary_config : @feature_config
|
|
117
|
+
else
|
|
118
|
+
@default_config
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
attr_reader :default_config
|
|
123
|
+
|
|
124
|
+
# Performance monitoring
|
|
125
|
+
def performance_stats
|
|
126
|
+
return {} unless performance_monitoring_enabled?
|
|
127
|
+
|
|
128
|
+
{
|
|
129
|
+
average_execution_time: @execution_times&.sum&./([@execution_times.size, 1].max) || 0,
|
|
130
|
+
total_executions: @execution_count || 0,
|
|
131
|
+
error_rate: calculate_error_rate,
|
|
132
|
+
last_execution: @last_execution_time
|
|
133
|
+
}
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Enhanced introspection with application-specific data
|
|
137
|
+
def to_h
|
|
138
|
+
base_data = super
|
|
139
|
+
base_data.merge(
|
|
140
|
+
configuration: configuration_summary,
|
|
141
|
+
performance: performance_stats,
|
|
142
|
+
environment: StarGates.configuration.environment,
|
|
143
|
+
feature_name: self.class.feature_name || feature_name
|
|
144
|
+
)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Class methods for configuration using ActiveSupport::Configurable
|
|
148
|
+
class << self
|
|
149
|
+
def configure(name = nil, **options)
|
|
150
|
+
self.feature_name = name if name
|
|
151
|
+
|
|
152
|
+
# Extract state-specific configs
|
|
153
|
+
state_specific = extract_state_configs(options)
|
|
154
|
+
self.state_configs = state_specific[:states] if state_specific[:states].any?
|
|
155
|
+
|
|
156
|
+
# Extract telemetry tags
|
|
157
|
+
self.telemetry_tags = extract_telemetry_tags(options)
|
|
158
|
+
|
|
159
|
+
# Set configuration using ActiveSupport::Configurable
|
|
160
|
+
remaining_options = options.except(*state_specific[:extracted_keys])
|
|
161
|
+
remaining_options.each do |key, value|
|
|
162
|
+
config.send("#{key}=", value)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def with_timeout(seconds, **options)
|
|
167
|
+
configure(timeout: seconds, **options)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def with_circuit_breaker(threshold: 5, **options)
|
|
171
|
+
configure(circuit_breaker_threshold: threshold, **options)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def for_team(team_name, **options)
|
|
175
|
+
self.telemetry_tags = { team: team_name }
|
|
176
|
+
configure(**options)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def critical_feature(**options)
|
|
180
|
+
configure(
|
|
181
|
+
timeout: 1.0,
|
|
182
|
+
retry_count: 5,
|
|
183
|
+
circuit_breaker_threshold: 10,
|
|
184
|
+
performance_monitoring: true,
|
|
185
|
+
**options
|
|
186
|
+
)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def experimental_feature(**options)
|
|
190
|
+
configure(
|
|
191
|
+
timeout: 10.0,
|
|
192
|
+
retry_count: 1,
|
|
193
|
+
performance_monitoring: true,
|
|
194
|
+
**options
|
|
195
|
+
)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
private
|
|
199
|
+
|
|
200
|
+
def extract_state_configs(options)
|
|
201
|
+
state_keys = %i[enabled_config disabled_config canary_config]
|
|
202
|
+
extracted = {}
|
|
203
|
+
|
|
204
|
+
state_keys.each do |key|
|
|
205
|
+
if options.key?(key)
|
|
206
|
+
state_name = key.to_s.sub('_config', '').to_sym
|
|
207
|
+
extracted[state_name] = options[key]
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
{
|
|
212
|
+
states: extracted,
|
|
213
|
+
extracted_keys: state_keys
|
|
214
|
+
}
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def extract_telemetry_tags(options)
|
|
218
|
+
options.select { |k, _| k.to_s.start_with?('tag_') }
|
|
219
|
+
.transform_keys { |k| k.to_s.sub(/^tag_/, '').to_sym }
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
private
|
|
224
|
+
|
|
225
|
+
def telemetry_enabled?
|
|
226
|
+
@merged_config[:instrumentation] && StarGates.telemetry
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def error_reporting_enabled?
|
|
230
|
+
@merged_config[:error_reporting]
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def setup_telemetry
|
|
234
|
+
@execution_times = []
|
|
235
|
+
@execution_count = 0
|
|
236
|
+
@error_count = 0
|
|
237
|
+
@last_execution_time = nil
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def setup_error_handling
|
|
241
|
+
# Setup error tracking and reporting
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def instrument_execution(context)
|
|
245
|
+
return yield unless telemetry_enabled?
|
|
246
|
+
|
|
247
|
+
# Use ActiveSupport::Notifications for instrumentation
|
|
248
|
+
ActiveSupport::Notifications.instrument('star_gates.feature.execute',
|
|
249
|
+
feature: feature_identifier,
|
|
250
|
+
status: status,
|
|
251
|
+
context: context) do |payload|
|
|
252
|
+
start_time = Time.now
|
|
253
|
+
begin
|
|
254
|
+
result = yield
|
|
255
|
+
execution_time = Time.now - start_time
|
|
256
|
+
record_successful_execution(execution_time)
|
|
257
|
+
|
|
258
|
+
payload[:success] = true
|
|
259
|
+
payload[:execution_time_ms] = (execution_time * 1000).round(2)
|
|
260
|
+
|
|
261
|
+
result
|
|
262
|
+
rescue StandardError => e
|
|
263
|
+
execution_time = Time.now - start_time
|
|
264
|
+
record_failed_execution(execution_time, e)
|
|
265
|
+
|
|
266
|
+
payload[:success] = false
|
|
267
|
+
payload[:execution_time_ms] = (execution_time * 1000).round(2)
|
|
268
|
+
payload[:error] = e.class.name
|
|
269
|
+
|
|
270
|
+
raise
|
|
271
|
+
ensure
|
|
272
|
+
StarGates.telemetry&.record_feature_execution(
|
|
273
|
+
feature_identifier,
|
|
274
|
+
status,
|
|
275
|
+
!defined?(e),
|
|
276
|
+
payload[:execution_time_ms],
|
|
277
|
+
context
|
|
278
|
+
)
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def instrument_ui_access(context)
|
|
284
|
+
return yield unless telemetry_enabled?
|
|
285
|
+
|
|
286
|
+
# Use ActiveSupport::Notifications for UI access tracking
|
|
287
|
+
ActiveSupport::Notifications.instrument('star_gates.ui_component.access',
|
|
288
|
+
feature: feature_identifier,
|
|
289
|
+
context: context) do |payload|
|
|
290
|
+
result = yield
|
|
291
|
+
payload[:component] = result
|
|
292
|
+
|
|
293
|
+
StarGates.telemetry&.record_counter(
|
|
294
|
+
'star_gates.ui_component.access',
|
|
295
|
+
1,
|
|
296
|
+
tags: telemetry_tags.merge(component: result, feature: feature_identifier)
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
result
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def instrument_config_access(key, context)
|
|
304
|
+
return yield unless telemetry_enabled?
|
|
305
|
+
|
|
306
|
+
# Use ActiveSupport::Notifications for config access tracking
|
|
307
|
+
ActiveSupport::Notifications.instrument('star_gates.config.access',
|
|
308
|
+
feature: feature_identifier,
|
|
309
|
+
config_key: key,
|
|
310
|
+
context: context) do |payload|
|
|
311
|
+
result = yield
|
|
312
|
+
payload[:config_value] = result
|
|
313
|
+
|
|
314
|
+
StarGates.telemetry&.record_counter(
|
|
315
|
+
'star_gates.config.access',
|
|
316
|
+
1,
|
|
317
|
+
tags: telemetry_tags.merge(config_key: key, feature: feature_identifier)
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
result
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def with_timeout(seconds, &)
|
|
325
|
+
if defined?(Timeout)
|
|
326
|
+
Timeout.timeout(seconds, &)
|
|
327
|
+
else
|
|
328
|
+
yield
|
|
329
|
+
end
|
|
330
|
+
rescue Timeout::Error
|
|
331
|
+
raise StarGates::UnavailableError, "Feature #{feature_identifier} timed out after #{seconds}s"
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def record_successful_execution(duration)
|
|
335
|
+
return unless performance_monitoring_enabled?
|
|
336
|
+
|
|
337
|
+
@execution_times << duration
|
|
338
|
+
@execution_count += 1
|
|
339
|
+
@last_execution_time = Time.now
|
|
340
|
+
|
|
341
|
+
# Keep only last 100 measurements for memory efficiency
|
|
342
|
+
@execution_times = @execution_times.last(100) if @execution_times.size > 100
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def record_failed_execution(duration, _error)
|
|
346
|
+
return unless performance_monitoring_enabled?
|
|
347
|
+
|
|
348
|
+
@execution_times << duration
|
|
349
|
+
@execution_count += 1
|
|
350
|
+
@error_count += 1
|
|
351
|
+
@last_execution_time = Time.now
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def calculate_error_rate
|
|
355
|
+
return 0 if @execution_count.nil? || @execution_count.zero?
|
|
356
|
+
|
|
357
|
+
(@error_count.to_f / @execution_count * 100).round(2)
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def telemetry_tags
|
|
361
|
+
base_tags = {
|
|
362
|
+
feature: feature_identifier,
|
|
363
|
+
state: status,
|
|
364
|
+
environment: StarGates.configuration.environment
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
class_tags = self.class.telemetry_tags || {}
|
|
368
|
+
base_tags.merge(class_tags)
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# State-specific configuration overrides
|
|
372
|
+
def apply_state_specific_config
|
|
373
|
+
case status
|
|
374
|
+
when 'disabled'
|
|
375
|
+
@current_config = @default_config
|
|
376
|
+
when 'canary'
|
|
377
|
+
@current_config = @canary_config.any? ? @canary_config : @feature_config
|
|
378
|
+
when 'enabled'
|
|
379
|
+
@current_config = @feature_config
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
# Add string extensions for better naming
|
|
386
|
+
unless String.method_defined?(:demodulize)
|
|
387
|
+
class String
|
|
388
|
+
def demodulize
|
|
389
|
+
split('::').last
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def underscore
|
|
393
|
+
gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
394
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
395
|
+
.downcase
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
end
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'chrono_machines'
|
|
4
|
+
|
|
5
|
+
module StarGates
|
|
6
|
+
class Circuit
|
|
7
|
+
attr_reader :breaker, :threshold, :within, :reset_after, :feature
|
|
8
|
+
attr_accessor :retry_policy
|
|
9
|
+
|
|
10
|
+
def initialize(threshold: 5, within: 30, reset_after: 60, retry_config: nil, feature: nil, auto_rollback: true)
|
|
11
|
+
@threshold = threshold
|
|
12
|
+
@within = parse_time(within)
|
|
13
|
+
@reset_after = parse_time(reset_after)
|
|
14
|
+
@feature = feature
|
|
15
|
+
@auto_rollback = auto_rollback
|
|
16
|
+
@breaker = create_breaker
|
|
17
|
+
@retry_policy = build_retry_policy(retry_config)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call(&block)
|
|
21
|
+
# Integrate ChronoMachines for exponential backoff retries
|
|
22
|
+
# This prevents the "retry death spiral" by adding jittered delays
|
|
23
|
+
if @retry_policy
|
|
24
|
+
ChronoMachines.retry(@retry_policy) do
|
|
25
|
+
breaker.call(&block)
|
|
26
|
+
end
|
|
27
|
+
else
|
|
28
|
+
breaker.call(&block)
|
|
29
|
+
end
|
|
30
|
+
rescue ChronoMachines::MaxRetriesExceededError => e
|
|
31
|
+
# Re-raise the original exception after retries exhausted
|
|
32
|
+
raise e.original_exception
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def open?
|
|
36
|
+
breaker.open?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def closed?
|
|
40
|
+
breaker.closed?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def half_open?
|
|
44
|
+
breaker.half_open?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def last_failure_time
|
|
48
|
+
# BreakerMachines stores this as last_failure_at in stats
|
|
49
|
+
stats = breaker.stats
|
|
50
|
+
stats.last_failure_at
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def failure_count
|
|
54
|
+
# BreakerMachines stores this in stats
|
|
55
|
+
stats = breaker.stats
|
|
56
|
+
stats.failure_count || 0
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def success_count
|
|
60
|
+
# BreakerMachines stores this in stats
|
|
61
|
+
stats = breaker.stats
|
|
62
|
+
stats.success_count || 0
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def reset!
|
|
66
|
+
breaker.reset
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def create_breaker
|
|
72
|
+
# Generate a unique circuit name for this instance
|
|
73
|
+
circuit_name = "star_gates_circuit_#{object_id}"
|
|
74
|
+
|
|
75
|
+
config = {
|
|
76
|
+
failure_threshold: threshold,
|
|
77
|
+
failure_window: within,
|
|
78
|
+
reset_timeout: reset_after,
|
|
79
|
+
exceptions: [StandardError]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
# Self-healing: wire circuit state to feature state
|
|
83
|
+
if @feature && @auto_rollback
|
|
84
|
+
config[:on_open] = -> { handle_circuit_open }
|
|
85
|
+
config[:on_half_open] = -> { handle_circuit_half_open }
|
|
86
|
+
config[:on_close] = -> { handle_circuit_close }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
BreakerMachines::Circuit.new(circuit_name, config)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Self-healing callbacks
|
|
93
|
+
def handle_circuit_open
|
|
94
|
+
return unless @feature.respond_to?(:status)
|
|
95
|
+
|
|
96
|
+
StarGates.instrument('circuit.auto_rollback', {
|
|
97
|
+
feature_status: @feature.status,
|
|
98
|
+
circuit_state: 'open',
|
|
99
|
+
action: 'rolling_back'
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
# Auto-rollback feature state when circuit opens
|
|
103
|
+
case @feature.status
|
|
104
|
+
when 'enabled'
|
|
105
|
+
# Circuit is failing → rollback to canary
|
|
106
|
+
@feature.rollout_canary if @feature.respond_to?(:rollout_canary)
|
|
107
|
+
when 'canary'
|
|
108
|
+
# Circuit still failing in canary → disable completely
|
|
109
|
+
@feature.disable if @feature.respond_to?(:disable)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def handle_circuit_half_open
|
|
114
|
+
return unless @feature
|
|
115
|
+
|
|
116
|
+
StarGates.instrument('circuit.half_open', {
|
|
117
|
+
feature_status: @feature.status,
|
|
118
|
+
circuit_state: 'half_open',
|
|
119
|
+
action: 'testing_recovery'
|
|
120
|
+
})
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def handle_circuit_close
|
|
124
|
+
return unless @feature
|
|
125
|
+
|
|
126
|
+
StarGates.instrument('circuit.recovered', {
|
|
127
|
+
feature_status: @feature.status,
|
|
128
|
+
circuit_state: 'closed',
|
|
129
|
+
action: 'circuit_healthy'
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
# Optionally: auto-restore to canary when circuit closes
|
|
133
|
+
# (conservative: don't auto-enable to 100%, just canary)
|
|
134
|
+
# Uncomment if you want auto-restoration:
|
|
135
|
+
# if @feature.status == 'disabled'
|
|
136
|
+
# @feature.rollout_canary if @feature.respond_to?(:rollout_canary)
|
|
137
|
+
# end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def parse_time(time)
|
|
141
|
+
# Rails duration helpers can return fractional seconds.
|
|
142
|
+
case time
|
|
143
|
+
when Numeric then time
|
|
144
|
+
when ActiveSupport::Duration then time.to_f
|
|
145
|
+
else time.to_i
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def build_retry_policy(config)
|
|
150
|
+
return nil unless config
|
|
151
|
+
|
|
152
|
+
# Default retry policy for circuit breakers
|
|
153
|
+
defaults = {
|
|
154
|
+
max_attempts: 3,
|
|
155
|
+
base_delay: 0.1,
|
|
156
|
+
multiplier: 2,
|
|
157
|
+
max_delay: 5,
|
|
158
|
+
retryable_exceptions: [StandardError],
|
|
159
|
+
on_retry: lambda { |exception:, attempt:, next_delay:|
|
|
160
|
+
StarGates.instrument('circuit.retry', {
|
|
161
|
+
exception: exception.class.name,
|
|
162
|
+
attempt: attempt,
|
|
163
|
+
next_delay: next_delay
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
defaults.merge(config)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|