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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/.dictate.toml +19 -0
  3. data/.pre-commit-config.yaml +20 -0
  4. data/CHANGELOG.md +60 -3
  5. data/LICENSE.txt +23 -16
  6. data/README.md +182 -16
  7. data/Rakefile +14 -4
  8. data/lib/star_gates/adapters/base_adapter.rb +65 -0
  9. data/lib/star_gates/adapters/memory_adapter.rb +93 -0
  10. data/lib/star_gates/adapters/redis_adapter.rb +119 -0
  11. data/lib/star_gates/application_star_gate.rb +398 -0
  12. data/lib/star_gates/circuit.rb +171 -0
  13. data/lib/star_gates/configuration.rb +172 -0
  14. data/lib/star_gates/dsl.rb +71 -2
  15. data/lib/star_gates/errors.rb +8 -0
  16. data/lib/star_gates/event_store/event_store.rb +79 -0
  17. data/lib/star_gates/event_store/memory_adapter.rb +78 -0
  18. data/lib/star_gates/events/base_event.rb +45 -0
  19. data/lib/star_gates/events/feature_events.rb +97 -0
  20. data/lib/star_gates/feature.rb +426 -0
  21. data/lib/star_gates/flags.rb +153 -0
  22. data/lib/star_gates/gate.rb +100 -0
  23. data/lib/star_gates/hexagonal/feature_aggregate.rb +250 -0
  24. data/lib/star_gates/hexagonal/feature_repository.rb +67 -0
  25. data/lib/star_gates/hexagonal/feature_service.rb +121 -0
  26. data/lib/star_gates/jsonrpc_rails_adapter.rb +251 -0
  27. data/lib/star_gates/projections/feature_projection.rb +224 -0
  28. data/lib/star_gates/registry.rb +58 -0
  29. data/lib/star_gates/telemetry/opentelemetry_adapter.rb +252 -0
  30. data/lib/star_gates/telemetry/prometheus_adapter.rb +183 -0
  31. data/lib/star_gates/telemetry/telemetry_adapter.rb +160 -0
  32. data/lib/star_gates/version.rb +1 -1
  33. data/lib/star_gates.rb +308 -8
  34. metadata +95 -14
  35. data/sig/star_gates.rbs +0 -4
@@ -0,0 +1,250 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../events/feature_events'
4
+
5
+ module StarGates
6
+ module Hexagonal
7
+ class FeatureAggregate
8
+ attr_reader :id, :version, :uncommitted_events
9
+
10
+ def initialize(id, events = [])
11
+ @id = id
12
+ @version = 0
13
+ @uncommitted_events = []
14
+ @state = {}
15
+
16
+ # Replay events to rebuild state
17
+ events.each { |event| apply_event(event, from_history: true) }
18
+ end
19
+
20
+ # Command: Create feature
21
+ def create(rollout: 0, group: nil, metadata: {})
22
+ raise ArgumentError, 'Feature already exists' if created?
23
+
24
+ event = Events::FeatureCreated.new(
25
+ aggregate_id: @id,
26
+ data: {
27
+ rollout_percentage: parse_rollout(rollout),
28
+ group_matcher: group,
29
+ metadata: metadata
30
+ }
31
+ )
32
+
33
+ apply_event(event)
34
+ end
35
+
36
+ # Command: Change state
37
+ def change_state(from_state, to_state, event_name, context = {})
38
+ unless can_transition?(from_state, to_state, event_name)
39
+ raise InvalidTransitionError, "Cannot transition from #{from_state} to #{to_state} via #{event_name}"
40
+ end
41
+
42
+ event = Events::FeatureStateChanged.new(
43
+ aggregate_id: @id,
44
+ from_state: from_state,
45
+ to_state: to_state,
46
+ event_name: event_name,
47
+ context: context
48
+ )
49
+
50
+ apply_event(event)
51
+ end
52
+
53
+ # Command: Update configuration
54
+ def update_configuration(changes)
55
+ event = Events::FeatureConfigurationChanged.new(
56
+ aggregate_id: @id,
57
+ configuration_changes: changes
58
+ )
59
+
60
+ apply_event(event)
61
+ end
62
+
63
+ # Command: Record execution
64
+ def record_execution(context, result, execution_time)
65
+ event = Events::FeatureExecuted.new(
66
+ aggregate_id: @id,
67
+ context: context,
68
+ result: result,
69
+ execution_time: execution_time
70
+ )
71
+
72
+ apply_event(event)
73
+ end
74
+
75
+ # Command: Record evaluation
76
+ def record_evaluation(context, eligible, reason)
77
+ event = Events::FeatureEvaluated.new(
78
+ aggregate_id: @id,
79
+ context: context,
80
+ eligible: eligible,
81
+ reason: reason
82
+ )
83
+
84
+ apply_event(event)
85
+ end
86
+
87
+ # Queries
88
+ def created?
89
+ @state[:created] || false
90
+ end
91
+
92
+ def current_state
93
+ @state[:current_state] || :disabled
94
+ end
95
+
96
+ def rollout_percentage
97
+ @state[:rollout_percentage] || 0
98
+ end
99
+
100
+ def group_matcher
101
+ @state[:group_matcher]
102
+ end
103
+
104
+ def configuration
105
+ @state[:configuration] || {}
106
+ end
107
+
108
+ def transition_history
109
+ @state[:transitions] || []
110
+ end
111
+
112
+ def execution_history
113
+ @state[:executions] || []
114
+ end
115
+
116
+ def evaluation_history
117
+ @state[:evaluations] || []
118
+ end
119
+
120
+ # Mark events as committed
121
+ def mark_events_as_committed
122
+ @uncommitted_events.clear
123
+ end
124
+
125
+ # Get snapshot for rebuilding
126
+ def to_snapshot
127
+ {
128
+ id: @id,
129
+ version: @version,
130
+ state: @state.dup
131
+ }
132
+ end
133
+
134
+ # Load from snapshot
135
+ def self.from_snapshot(snapshot, events_since_snapshot = [])
136
+ aggregate = allocate
137
+ aggregate.instance_variable_set(:@id, snapshot[:id])
138
+ aggregate.instance_variable_set(:@version, snapshot[:version])
139
+ aggregate.instance_variable_set(:@state, snapshot[:state].dup)
140
+ aggregate.instance_variable_set(:@uncommitted_events, [])
141
+
142
+ # Apply events since snapshot
143
+ events_since_snapshot.each { |event| aggregate.apply_event(event, from_history: true) }
144
+
145
+ aggregate
146
+ end
147
+
148
+ private
149
+
150
+ def apply_event(event, from_history: false)
151
+ case event
152
+ when Events::FeatureCreated
153
+ apply_feature_created(event)
154
+ when Events::FeatureStateChanged
155
+ apply_state_changed(event)
156
+ when Events::FeatureConfigurationChanged
157
+ apply_configuration_changed(event)
158
+ when Events::FeatureExecuted
159
+ apply_execution_recorded(event)
160
+ when Events::FeatureEvaluated
161
+ apply_evaluation_recorded(event)
162
+ end
163
+
164
+ @version += 1 if from_history
165
+ @uncommitted_events << event unless from_history
166
+ end
167
+
168
+ def apply_feature_created(event)
169
+ @state[:created] = true
170
+ @state[:current_state] = :disabled
171
+ @state[:rollout_percentage] = event.data[:rollout_percentage]
172
+ @state[:group_matcher] = event.data[:group_matcher]
173
+ @state[:metadata] = event.data[:metadata]
174
+ @state[:transitions] = []
175
+ @state[:executions] = []
176
+ @state[:evaluations] = []
177
+ @state[:configuration] = {}
178
+ end
179
+
180
+ def apply_state_changed(event)
181
+ @state[:current_state] = event.to_state
182
+ @state[:transitions] ||= []
183
+ @state[:transitions] << {
184
+ from_state: event.from_state,
185
+ to_state: event.to_state,
186
+ event_name: event.event_name,
187
+ context: event.context,
188
+ occurred_at: event.occurred_at
189
+ }
190
+
191
+ # Keep only last 50 transitions for memory efficiency
192
+ @state[:transitions] = @state[:transitions].last(50) if @state[:transitions].size > 50
193
+ end
194
+
195
+ def apply_configuration_changed(event)
196
+ @state[:configuration] ||= {}
197
+ @state[:configuration].merge!(event.configuration_changes)
198
+ end
199
+
200
+ def apply_execution_recorded(event)
201
+ @state[:executions] ||= []
202
+ @state[:executions] << {
203
+ context: event.context,
204
+ result: event.result,
205
+ execution_time: event.execution_time,
206
+ occurred_at: event.occurred_at
207
+ }
208
+
209
+ # Keep only last 100 executions
210
+ @state[:executions] = @state[:executions].last(100) if @state[:executions].size > 100
211
+ end
212
+
213
+ def apply_evaluation_recorded(event)
214
+ @state[:evaluations] ||= []
215
+ @state[:evaluations] << {
216
+ context: event.context,
217
+ eligible: event.eligible,
218
+ reason: event.reason,
219
+ occurred_at: event.occurred_at
220
+ }
221
+
222
+ # Keep only last 100 evaluations
223
+ @state[:evaluations] = @state[:evaluations].last(100) if @state[:evaluations].size > 100
224
+ end
225
+
226
+ def can_transition?(from_state, to_state, event_name)
227
+ # Define valid transitions
228
+ valid_transitions = {
229
+ disabled: { rollout_canary: :canary, enable: :enabled },
230
+ canary: { enable: :enabled, disable: :disabled },
231
+ enabled: { disable: :disabled, rollout_canary: :canary }
232
+ }
233
+
234
+ allowed_transitions = valid_transitions[from_state.to_sym] || {}
235
+ allowed_transitions[event_name.to_sym] == to_state.to_sym
236
+ end
237
+
238
+ def parse_rollout(rollout)
239
+ case rollout
240
+ when Numeric, String
241
+ rollout.to_i
242
+ else
243
+ 0
244
+ end
245
+ end
246
+
247
+ class InvalidTransitionError < StandardError; end
248
+ end
249
+ end
250
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'feature_aggregate'
4
+
5
+ module StarGates
6
+ module Hexagonal
7
+ class FeatureRepository
8
+ def initialize(event_store)
9
+ @event_store = event_store
10
+ end
11
+
12
+ # Load feature aggregate from event store
13
+ def find(feature_id)
14
+ events = @event_store.get_events(feature_id)
15
+ return nil if events.empty?
16
+
17
+ FeatureAggregate.new(feature_id, events)
18
+ end
19
+
20
+ # Save feature aggregate to event store
21
+ def save(aggregate, expected_version: nil)
22
+ return if aggregate.uncommitted_events.empty?
23
+
24
+ @event_store.append(
25
+ aggregate.id,
26
+ aggregate.uncommitted_events,
27
+ expected_version: expected_version
28
+ )
29
+
30
+ aggregate.mark_events_as_committed
31
+ end
32
+
33
+ # Find or create feature aggregate
34
+ def find_or_create(feature_id)
35
+ find(feature_id) || FeatureAggregate.new(feature_id)
36
+ end
37
+
38
+ # Check if feature exists
39
+ def exists?(feature_id)
40
+ @event_store.get_version(feature_id).positive?
41
+ end
42
+
43
+ # Get all feature IDs that have events
44
+ def all_feature_ids
45
+ # This would need to be implemented by the event store adapter
46
+ # For now, we'll return an empty array as this requires scanning
47
+ []
48
+ end
49
+
50
+ # Get features by state (requires read model/projection)
51
+ def find_by_state(state)
52
+ # This would typically be implemented using a read model/projection
53
+ # For now, we'll raise an error to indicate this needs a projection
54
+ raise NotImplementedError, 'Use FeatureProjection to query by state'
55
+ end
56
+
57
+ # Health check
58
+ def healthy?
59
+ @event_store.respond_to?(:healthy?) ? @event_store.healthy? : true
60
+ end
61
+
62
+ private
63
+
64
+ attr_reader :event_store
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'feature_repository'
4
+ require_relative '../projections/feature_projection'
5
+
6
+ module StarGates
7
+ module Hexagonal
8
+ class FeatureService
9
+ def initialize(repository:, projection:, event_store:)
10
+ @repository = repository
11
+ @projection = projection
12
+ @event_store = event_store
13
+ setup_event_subscriptions
14
+ end
15
+
16
+ # Commands
17
+ def create_feature(feature_id, rollout: 0, group: nil, metadata: {})
18
+ aggregate = @repository.find_or_create(feature_id)
19
+
20
+ unless aggregate.created?
21
+ aggregate.create(rollout: rollout, group: group, metadata: metadata)
22
+ @repository.save(aggregate)
23
+ end
24
+
25
+ aggregate
26
+ end
27
+
28
+ def change_feature_state(feature_id, from_state, to_state, event_name, context = {})
29
+ aggregate = @repository.find(feature_id)
30
+ raise FeatureNotFoundError, "Feature #{feature_id} not found" unless aggregate
31
+
32
+ aggregate.change_state(from_state, to_state, event_name, context)
33
+ @repository.save(aggregate)
34
+
35
+ aggregate
36
+ end
37
+
38
+ def update_feature_configuration(feature_id, changes)
39
+ aggregate = @repository.find(feature_id)
40
+ raise FeatureNotFoundError, "Feature #{feature_id} not found" unless aggregate
41
+
42
+ aggregate.update_configuration(changes)
43
+ @repository.save(aggregate)
44
+
45
+ aggregate
46
+ end
47
+
48
+ def record_feature_execution(feature_id, context, result, execution_time)
49
+ aggregate = @repository.find(feature_id)
50
+ return unless aggregate # Silently skip if feature doesn't exist
51
+
52
+ aggregate.record_execution(context, result, execution_time)
53
+ @repository.save(aggregate)
54
+ end
55
+
56
+ def record_feature_evaluation(feature_id, context, eligible, reason)
57
+ aggregate = @repository.find(feature_id)
58
+ return unless aggregate # Silently skip if feature doesn't exist
59
+
60
+ aggregate.record_evaluation(context, eligible, reason)
61
+ @repository.save(aggregate)
62
+ end
63
+
64
+ # Queries (delegated to projection)
65
+ def find_feature(feature_id)
66
+ @projection.find_feature(feature_id)
67
+ end
68
+
69
+ def find_features_by_state(state)
70
+ @projection.find_features_by_state(state)
71
+ end
72
+
73
+ def all_features
74
+ @projection.all_features
75
+ end
76
+
77
+ def feature_stats
78
+ @projection.feature_stats
79
+ end
80
+
81
+ def recent_transitions(limit: 10)
82
+ @projection.recent_transitions(limit: limit)
83
+ end
84
+
85
+ def feature_exists?(feature_id)
86
+ @projection.feature_exists?(feature_id)
87
+ end
88
+
89
+ # Get feature aggregate for complex operations
90
+ def get_feature_aggregate(feature_id)
91
+ @repository.find(feature_id)
92
+ end
93
+
94
+ # Replay events to rebuild projections
95
+ def rebuild_projections!
96
+ @projection.clear! if @projection.respond_to?(:clear!)
97
+
98
+ @event_store.replay_all do |event|
99
+ @projection.handle_event(event)
100
+ end
101
+ end
102
+
103
+ # Health check
104
+ def healthy?
105
+ @repository.healthy? &&
106
+ (@event_store.respond_to?(:healthy?) ? @event_store.healthy? : true)
107
+ end
108
+
109
+ private
110
+
111
+ def setup_event_subscriptions
112
+ # Subscribe to all events to update projections
113
+ @event_store.subscribe do |event|
114
+ @projection.handle_event(event)
115
+ end
116
+ end
117
+
118
+ class FeatureNotFoundError < StandardError; end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,251 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StarGates
4
+ # Optional JSON-RPC adapter using jsonrpc-rails gem
5
+ # Include this in your Rails controllers when jsonrpc-rails is available
6
+ module JsonrpcRailsAdapter
7
+ def self.available?
8
+ defined?(JSON_RPC) && defined?(ActionController::Base)
9
+ end
10
+
11
+ def self.included(_base)
12
+ return if available?
13
+
14
+ raise LoadError, "jsonrpc-rails gem is required. Add 'gem \"jsonrpc-rails\"' to your Gemfile"
15
+ end
16
+
17
+ # Main JSON-RPC request handler
18
+ def process
19
+ if jsonrpc_params_batch?
20
+ # Handle batch requests
21
+ responses = jsonrpc_params.filter_map { |req| handle_single_request(req) }
22
+
23
+ if responses.empty?
24
+ head :no_content
25
+ else
26
+ render json: responses.map(&:to_h), status: :ok
27
+ end
28
+ else
29
+ # Handle single request
30
+ response = handle_single_request(jsonrpc_params)
31
+
32
+ if response # request (has id)
33
+ render jsonrpc: response.result,
34
+ id: response.id,
35
+ error: response.error
36
+ else # notification
37
+ head :no_content
38
+ end
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ # Map one JSON_RPC::Request|Notification → Response|nil
45
+ def handle_single_request(req)
46
+ # Notifications have id == nil. Return nil = no response.
47
+ return nil if req.id.nil?
48
+
49
+ result_or_error = dispatch_method(req.method, req.params || {})
50
+ build_response(req.id, result_or_error)
51
+ end
52
+
53
+ # Dispatch JSON-RPC method to appropriate handler
54
+ def dispatch_method(method, params)
55
+ case method
56
+ when 'feature.create'
57
+ create_feature(params)
58
+ when 'feature.get'
59
+ get_feature(params)
60
+ when 'feature.list'
61
+ list_features(params)
62
+ when 'feature.changeState'
63
+ change_state(params)
64
+ when 'feature.execute'
65
+ execute_feature(params)
66
+ when 'feature.evaluate'
67
+ evaluate_feature(params)
68
+ when 'feature.stats'
69
+ feature_stats(params)
70
+ when 'feature.recentTransitions'
71
+ recent_transitions(params)
72
+ when 'system.health'
73
+ system_health(params)
74
+ when 'system.rebuildProjections'
75
+ rebuild_projections(params)
76
+ else
77
+ JSON_RPC::JsonRpcError.build(:method_not_found)
78
+ end
79
+ end
80
+
81
+ # Feature management methods
82
+ def create_feature(params)
83
+ validate_required_params(params, ['featureId'])
84
+
85
+ feature_id = params['featureId']
86
+ rollout = params['rollout'] || 0
87
+ group = params['group']
88
+ metadata = params['metadata'] || {}
89
+
90
+ aggregate = feature_service.create_feature(feature_id, rollout: rollout, group: group, metadata: metadata)
91
+
92
+ {
93
+ featureId: aggregate.id,
94
+ created: aggregate.created?,
95
+ state: aggregate.current_state,
96
+ rolloutPercentage: aggregate.rollout_percentage,
97
+ groupMatcher: aggregate.group_matcher
98
+ }
99
+ rescue StandardError => e
100
+ JSON_RPC::JsonRpcError.build(:invalid_params, data: { error: e.message })
101
+ end
102
+
103
+ def get_feature(params)
104
+ validate_required_params(params, ['featureId'])
105
+
106
+ feature = feature_service.find_feature(params['featureId'])
107
+ return JSON_RPC::JsonRpcError.build(:invalid_params, data: { error: 'Feature not found' }) unless feature
108
+
109
+ feature
110
+ rescue StandardError => e
111
+ JSON_RPC::JsonRpcError.build(:internal_error, data: { error: e.message })
112
+ end
113
+
114
+ def list_features(params)
115
+ state = params['state']
116
+
117
+ features = if state
118
+ feature_service.find_features_by_state(state.to_sym)
119
+ else
120
+ feature_service.all_features
121
+ end
122
+
123
+ { features: features }
124
+ rescue StandardError => e
125
+ JSON_RPC::JsonRpcError.build(:internal_error, data: { error: e.message })
126
+ end
127
+
128
+ def change_state(params)
129
+ validate_required_params(params, %w[featureId fromState toState event])
130
+
131
+ feature_id = params['featureId']
132
+ from_state = params['fromState'].to_sym
133
+ to_state = params['toState'].to_sym
134
+ event_name = params['event'].to_sym
135
+ context = params['context'] || {}
136
+
137
+ aggregate = feature_service.change_feature_state(feature_id, from_state, to_state, event_name, context)
138
+
139
+ {
140
+ featureId: aggregate.id,
141
+ fromState: from_state,
142
+ toState: to_state,
143
+ event: event_name,
144
+ success: true
145
+ }
146
+ rescue StandardError => e
147
+ JSON_RPC::JsonRpcError.build(:invalid_params, data: { error: "State transition failed: #{e.message}" })
148
+ end
149
+
150
+ def execute_feature(params)
151
+ validate_required_params(params, ['featureId'])
152
+
153
+ feature_id = params['featureId']
154
+ context = params['context'] || {}
155
+
156
+ start_time = Time.now
157
+ result = yield if block_given? # Allow caller to provide execution block
158
+ execution_time = ((Time.now - start_time) * 1000).round(2) # ms
159
+
160
+ feature_service.record_feature_execution(feature_id, context, result, execution_time)
161
+
162
+ {
163
+ featureId: feature_id,
164
+ result: result,
165
+ executionTime: execution_time,
166
+ context: context
167
+ }
168
+ rescue StandardError => e
169
+ JSON_RPC::JsonRpcError.build(:internal_error, data: { error: e.message })
170
+ end
171
+
172
+ def evaluate_feature(params)
173
+ validate_required_params(params, %w[featureId context])
174
+
175
+ feature_id = params['featureId']
176
+ context = params['context']
177
+ eligible = params['eligible'] || false
178
+ reason = params['reason'] || 'No reason provided'
179
+
180
+ feature_service.record_feature_evaluation(feature_id, context, eligible, reason)
181
+
182
+ {
183
+ featureId: feature_id,
184
+ context: context,
185
+ eligible: eligible,
186
+ reason: reason,
187
+ recordedAt: Time.now.utc.iso8601
188
+ }
189
+ rescue StandardError => e
190
+ JSON_RPC::JsonRpcError.build(:internal_error, data: { error: e.message })
191
+ end
192
+
193
+ def feature_stats(_params)
194
+ feature_service.feature_stats
195
+ rescue StandardError => e
196
+ JSON_RPC::JsonRpcError.build(:internal_error, data: { error: e.message })
197
+ end
198
+
199
+ def recent_transitions(params)
200
+ limit = params['limit'] || 10
201
+
202
+ {
203
+ transitions: feature_service.recent_transitions(limit: limit),
204
+ limit: limit
205
+ }
206
+ rescue StandardError => e
207
+ JSON_RPC::JsonRpcError.build(:internal_error, data: { error: e.message })
208
+ end
209
+
210
+ def system_health(_params)
211
+ {
212
+ healthy: feature_service.healthy?,
213
+ timestamp: Time.now.utc.iso8601,
214
+ version: StarGates::VERSION
215
+ }
216
+ rescue StandardError => e
217
+ JSON_RPC::JsonRpcError.build(:internal_error, data: { error: e.message })
218
+ end
219
+
220
+ def rebuild_projections(_params)
221
+ feature_service.rebuild_projections!
222
+
223
+ {
224
+ success: true,
225
+ rebuiltAt: Time.now.utc.iso8601
226
+ }
227
+ rescue StandardError => e
228
+ JSON_RPC::JsonRpcError.build(:internal_error, data: { error: e.message })
229
+ end
230
+
231
+ # Helper methods
232
+ def validate_required_params(params, required_keys)
233
+ missing_keys = required_keys - params.keys
234
+ return if missing_keys.empty?
235
+
236
+ raise ArgumentError, "Missing required parameters: #{missing_keys.join(', ')}"
237
+ end
238
+
239
+ def build_response(id, outcome)
240
+ if outcome.is_a?(JSON_RPC::JsonRpcError)
241
+ JSON_RPC::Response.new(id: id, error: outcome)
242
+ else
243
+ JSON_RPC::Response.new(id: id, result: outcome)
244
+ end
245
+ end
246
+
247
+ def feature_service
248
+ @feature_service ||= StarGates.feature_service
249
+ end
250
+ end
251
+ end