featurevisor 3.0.0 → 4.0.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.
@@ -6,8 +6,6 @@ module Featurevisor
6
6
  # Feature specific
7
7
  FEATURE_NOT_FOUND = "feature_not_found" # feature is not found in datafile
8
8
  DISABLED = "disabled" # feature is disabled
9
- REQUIRED = "required" # required features are not enabled
10
- OUT_OF_RANGE = "out_of_range" # out of range when mutually exclusive experiments are involved via Groups
11
9
 
12
10
  # Variations specific
13
11
  NO_VARIATIONS = "no_variations" # feature has no variations
@@ -19,7 +17,8 @@ module Featurevisor
19
17
  VARIABLE_DISABLED = "variable_disabled" # feature is disabled, and variable's disabledValue is used
20
18
  VARIABLE_OVERRIDE_VARIATION = "variable_override_variation" # variable overridden from inside a variation
21
19
  VARIABLE_OVERRIDE_RULE = "variable_override_rule" # variable overridden from inside a rule
22
- REQUIRED_FEATURES_UNMET = "required_features_unmet"
20
+ CONDITIONS_UNMET = "conditions_unmet"
21
+ VARIABLE_COMPOSED = "variable_composed"
23
22
 
24
23
  # Common
25
24
  NO_MATCH = "no_match" # no rules matched
@@ -36,49 +35,8 @@ module Featurevisor
36
35
 
37
36
  # Evaluation module for feature flag evaluation
38
37
  module Evaluate
39
- def self.required_features_are_matched(requirements, datafile, options)
40
- return true if requirements.nil?
41
-
42
- items = requirements.is_a?(Array) ? requirements : [requirements]
43
- clean_options = options.reject do |key, _|
44
- %i[feature_key variable_key default_variation_value default_variable_value].include?(key)
45
- end
46
- items.all? do |required|
47
- if required.is_a?(String)
48
- key = required
49
- enabled = true
50
- variation = nil
51
- elsif required.key?(:feature)
52
- key = required[:feature]
53
- enabled = required.key?(:enabled) ? required[:enabled] : true
54
- variation = required[:variation]
55
- else
56
- key = required[:key]
57
- enabled = true
58
- variation = required[:variation]
59
- end
60
-
61
- flag = evaluate_with_modules(clean_options.merge(type: "flag", feature_key: key, datafile: datafile))
62
- next false unless (flag[:enabled] == true) == enabled
63
- next true if variation.nil?
64
-
65
- evaluated_variation = evaluate_with_modules(clean_options.merge(type: "variation", feature_key: key, datafile: datafile))
66
- value = evaluated_variation.key?(:variation_value) ? evaluated_variation[:variation_value] : evaluated_variation.dig(:variation, :value)
67
- value == variation
68
- end
69
- end
70
-
71
38
  def self.variable_override_matches?(override, datafile, context, options)
72
- return false unless required_features_are_matched(override[:requiredFeatures], datafile, options)
73
-
74
- conditions_match = !override[:conditions] || datafile.all_conditions_are_matched(
75
- datafile.parse_conditions_if_stringified(override[:conditions]), context
76
- )
77
- segments_match = !override[:segments] || datafile.all_segments_are_matched(
78
- datafile.parse_segments_if_stringified(override[:segments]), context
79
- )
80
- conditions_match && segments_match &&
81
- (override.key?(:conditions) || override.key?(:segments) || override.key?(:requiredFeatures))
39
+ override.key?(:conditions) && UnifiedConditions.matches?(override[:conditions], options)
82
40
  end
83
41
 
84
42
  # Evaluate with modules
@@ -86,13 +44,36 @@ module Featurevisor
86
44
  # @return [Hash] Evaluation result
87
45
  def self.evaluate_with_modules(options)
88
46
  begin
47
+ session = options[:__featurevisor_session] || EvaluationSession.new
48
+ options = options.merge(__featurevisor_session: session)
49
+ if options[:__featurevisor_prerequisite]
50
+ options[:sticky] = options[:__featurevisor_sticky_features_source].call if options[:__featurevisor_sticky_features_source]
51
+ options[:sticky_variables] = options[:__featurevisor_sticky_variables_source].call if options[:__featurevisor_sticky_variables_source]
52
+ options[:datafile] = options[:__featurevisor_datafile_source].call if options[:__featurevisor_datafile_source]
53
+ end
54
+ options[:dependency_of] = session.dependency_of if options[:__featurevisor_prerequisite]
55
+ options[:dependency_purpose] ||= "condition" if options[:__featurevisor_prerequisite]
56
+ return session.run(options, prerequisite: options[:__featurevisor_prerequisite]) do
89
57
  modules_manager = options[:modules_manager]
90
-
91
- # Run before modules
58
+ # The usual read has no modules. Keep its evaluation options intact so
59
+ # it does not allocate two filtered hashes and a merged hash.
92
60
  result_options = options
93
- if modules_manager
94
- result_options = modules_manager.run_before_modules(result_options)
61
+ if modules_manager && !modules_manager.get_all.empty?
62
+ private_options = options.select { |key, _| private_evaluation_option?(key) }
63
+ public_options = options.reject { |key, _| private_evaluation_option?(key) }
64
+ result_options = modules_manager.run_before_evaluation_modules(public_options)
65
+ result_options = result_options.reject { |key, _| private_evaluation_option?(key) }.merge(private_options)
95
66
  end
67
+ { sticky: :__featurevisor_sticky_features_source,
68
+ sticky_variables: :__featurevisor_sticky_variables_source }.each do |field, source|
69
+ if result_options[field].equal?(options[field])
70
+ result_options[field] = options[source].call if options[source]
71
+ else
72
+ replacement = result_options[field]
73
+ result_options[source] = -> { replacement }
74
+ end
75
+ end
76
+ session.update_current(result_options)
96
77
 
97
78
  # Evaluate
98
79
  evaluation = evaluate(result_options)
@@ -113,12 +94,18 @@ module Featurevisor
113
94
  end
114
95
 
115
96
  # Run after modules
116
- if modules_manager
117
- evaluation = modules_manager.run_after_modules(evaluation, result_options)
97
+ # A callback during evaluation may add a module. In that case its
98
+ # after hook still receives only public options.
99
+ if modules_manager && !modules_manager.get_all.empty?
100
+ evaluation = modules_manager.run_after_evaluation_modules(
101
+ evaluation, result_options.reject { |key, _| private_evaluation_option?(key) }
102
+ )
118
103
  end
119
104
 
120
105
  evaluation
106
+ end
121
107
  rescue => e
108
+ raise if e.is_a?(RequirementError) && options[:__featurevisor_prerequisite]
122
109
  type = options[:type]
123
110
  feature_key = options[:feature_key]
124
111
  variable_key = options[:variable_key]
@@ -132,12 +119,18 @@ module Featurevisor
132
119
  error: e
133
120
  }
134
121
 
122
+ session.remember(options, evaluation) if session
123
+
135
124
  diagnostics.error("Error during evaluation", evaluation)
136
125
 
137
126
  evaluation
138
127
  end
139
128
  end
140
129
 
130
+ def self.private_evaluation_option?(key)
131
+ key.to_s.start_with?("__featurevisor_")
132
+ end
133
+
141
134
  # Main evaluation function
142
135
  # @param options [Hash] Evaluation options
143
136
  # @return [Hash] Evaluation result
@@ -228,7 +221,7 @@ module Featurevisor
228
221
  type: type,
229
222
  feature_key: feature_key,
230
223
  reason: Featurevisor::EvaluationReason::STICKY,
231
- sticky: sticky_feature,
224
+ sticky_feature: sticky_feature,
232
225
  enabled: sticky_feature[:enabled]
233
226
  }
234
227
 
@@ -341,7 +334,7 @@ module Featurevisor
341
334
  end
342
335
 
343
336
  # Forced
344
- force_result = datafile.get_matched_force(feature, context)
337
+ force_result = datafile.get_matched_force(feature, context, options)
345
338
  force = force_result[:force]
346
339
  force_index = force_result[:forceIndex]
347
340
 
@@ -402,25 +395,17 @@ module Featurevisor
402
395
  end
403
396
  end
404
397
 
405
- # Required
406
- required_features = feature[:requiredFeatures] || feature[:required]
407
- if type == "flag" && required_features && !Array(required_features).empty?
408
- required_features_are_enabled = required_features_are_matched(required_features, datafile, options)
409
-
410
- unless required_features_are_enabled
411
- evaluation = {
412
- type: type,
413
- feature_key: feature_key,
414
- reason: Featurevisor::EvaluationReason::REQUIRED,
415
- required: feature[:required],
416
- required_features: feature[:requiredFeatures],
417
- enabled: required_features_are_enabled
418
- }
419
-
420
- diagnostics.debug("required features not enabled", evaluation)
421
-
422
- return evaluation
423
- end
398
+ if type == "flag" && feature.key?(:conditions) &&
399
+ !UnifiedConditions.matches?(feature[:conditions], options)
400
+ evaluation = {
401
+ type: type,
402
+ feature_key: feature_key,
403
+ reason: Featurevisor::EvaluationReason::CONDITIONS_UNMET,
404
+ conditions: feature[:conditions],
405
+ enabled: false
406
+ }
407
+ diagnostics.debug("conditions did not match", evaluation)
408
+ return evaluation
424
409
  end
425
410
 
426
411
  # Bucketing
@@ -455,13 +440,13 @@ module Featurevisor
455
440
  matched_allocation = nil
456
441
 
457
442
  if type != "flag"
458
- matched_traffic = datafile.get_matched_traffic(feature[:traffic], context)
443
+ matched_traffic = datafile.get_matched_traffic(feature[:traffic], context, options)
459
444
 
460
445
  if matched_traffic
461
446
  matched_allocation = datafile.get_matched_allocation(matched_traffic, bucket_value)
462
447
  end
463
448
  else
464
- matched_traffic = datafile.get_matched_traffic(feature[:traffic], context)
449
+ matched_traffic = datafile.get_matched_traffic(feature[:traffic], context, options)
465
450
  end
466
451
 
467
452
  if matched_traffic
@@ -485,45 +470,6 @@ module Featurevisor
485
470
 
486
471
  # flag
487
472
  if type == "flag"
488
- # flag: check if mutually exclusive
489
- if feature[:ranges] && feature[:ranges].length > 0
490
- matched_range = feature[:ranges].find do |range|
491
- bucket_value >= range[0] && bucket_value < range[1]
492
- end
493
-
494
- # matched
495
- if matched_range
496
- evaluation = {
497
- type: type,
498
- feature_key: feature_key,
499
- reason: Featurevisor::EvaluationReason::ALLOCATED,
500
- bucket_key: bucket_key,
501
- bucket_value: bucket_value,
502
- rule_key: matched_traffic[:key],
503
- traffic: matched_traffic,
504
- enabled: matched_traffic[:enabled].nil? ? true : matched_traffic[:enabled]
505
- }
506
-
507
- diagnostics.debug("matched", evaluation)
508
-
509
- return evaluation
510
- end
511
-
512
- # no match
513
- evaluation = {
514
- type: type,
515
- feature_key: feature_key,
516
- reason: Featurevisor::EvaluationReason::OUT_OF_RANGE,
517
- bucket_key: bucket_key,
518
- bucket_value: bucket_value,
519
- enabled: false
520
- }
521
-
522
- diagnostics.debug("not matched", evaluation)
523
-
524
- return evaluation
525
- end
526
-
527
473
  # flag: override from rule
528
474
  if matched_traffic.key?(:enabled)
529
475
  evaluation = {
@@ -634,7 +580,8 @@ module Featurevisor
634
580
  variable_schema: variable_schema,
635
581
  variable_value: override[:value],
636
582
  variable_override_index: override_index,
637
- variable_override_key: override[:key]
583
+ variable_override_key: override[:key],
584
+ variable_override_path: override[:keyPath]
638
585
  }
639
586
 
640
587
  diagnostics.debug("variable override from rule", evaluation)
@@ -698,7 +645,8 @@ module Featurevisor
698
645
  variable_schema: variable_schema,
699
646
  variable_value: override[:value],
700
647
  variable_override_index: override_index,
701
- variable_override_key: override[:key]
648
+ variable_override_key: override[:key],
649
+ variable_override_path: override[:keyPath]
702
650
  }
703
651
 
704
652
  diagnostics.debug("variable override from variation", evaluation)
@@ -791,6 +739,7 @@ module Featurevisor
791
739
 
792
740
  evaluation
793
741
  rescue => e
742
+ raise if e.is_a?(RequirementError)
794
743
  evaluation = {
795
744
  type: type,
796
745
  feature_key: feature_key,
@@ -5,7 +5,8 @@ require "json"
5
5
  module Featurevisor
6
6
  # Private datafile and matching adapter used by the evaluator.
7
7
  class InstanceEvaluationDataProvider
8
- attr_reader :schema_version, :revision, :featurevisor_version, :segments, :features, :variables, :diagnostics, :regex_cache
8
+ attr_reader :schema_version, :revision, :featurevisor_version, :segments, :features, :variables, :diagnostics, :regex_cache, :normalized
9
+ attr_accessor :dependency_index
9
10
 
10
11
  # Initialize a new evaluation data provider.
11
12
  # @param options [Hash] Options hash containing datafile and diagnostics
@@ -21,38 +22,14 @@ module Featurevisor
21
22
  @segments = (datafile[:segments] || {}).transform_keys(&:to_sym)
22
23
  @features = (datafile[:features] || {}).transform_keys(&:to_sym)
23
24
  @variables = (datafile[:variables] || {}).transform_keys(&:to_sym)
24
-
25
- # Transform nested structures to use symbol keys
26
- @features.each do |_key, feature|
27
- if feature[:variablesSchema]
28
- feature[:variablesSchema] = feature[:variablesSchema].transform_keys(&:to_sym)
29
- end
30
- if feature[:variations]
31
- feature[:variations].each do |variation|
32
- if variation[:variables]
33
- variation[:variables] = variation[:variables].transform_keys(&:to_sym)
34
- end
35
- if variation[:variableOverrides]
36
- variation[:variableOverrides] = variation[:variableOverrides].transform_keys(&:to_sym)
37
- end
38
- end
39
- end
40
- if feature[:force]
41
- feature[:force].each do |force_rule|
42
- if force_rule[:variables]
43
- force_rule[:variables] = force_rule[:variables].transform_keys(&:to_sym)
44
- end
45
- end
46
- end
47
- if feature[:traffic]
48
- feature[:traffic].each do |traffic_rule|
49
- if traffic_rule[:variables]
50
- traffic_rule[:variables] = traffic_rule[:variables].transform_keys(&:to_sym)
51
- end
52
- end
53
- end
54
- end
25
+ @normalized = { feature: {}, segment: {}, variable: {} }
26
+ @normalization_slots = { feature: {}, segment: {}, variable: {} }
27
+ @normalization_mutex = Mutex.new
55
28
  @regex_cache = {}
29
+ @conditions_cache = {}
30
+ @segments_cache = {}
31
+ @attribute_conditions_cache = {}
32
+ @get_regex = method(:get_regex)
56
33
  end
57
34
 
58
35
  # Get the revision of the datafile
@@ -82,12 +59,7 @@ module Featurevisor
82
59
  # @param segment_key [String] Segment key to retrieve
83
60
  # @return [Hash, nil] Segment data or nil if not found
84
61
  def get_segment(segment_key)
85
- segment = @segments[segment_key.to_sym] || @segments[segment_key]
86
-
87
- return nil unless segment
88
-
89
- segment[:conditions] = parse_conditions_if_stringified(segment[:conditions])
90
- segment
62
+ read_entity(:segment, segment_key, @segments)
91
63
  end
92
64
 
93
65
  def get_segment_keys
@@ -104,7 +76,7 @@ module Featurevisor
104
76
  # @param feature_key [String] Feature key to retrieve
105
77
  # @return [Hash, nil] Feature data or nil if not found
106
78
  def get_feature(feature_key)
107
- @features[feature_key.to_sym] || @features[feature_key]
79
+ read_entity(:feature, feature_key, @features)
108
80
  end
109
81
 
110
82
  # Get variable keys for a feature
@@ -121,7 +93,60 @@ module Featurevisor
121
93
  end
122
94
 
123
95
  def get_global_variable(variable_key)
124
- @variables[variable_key.to_sym] || @variables[variable_key]
96
+ read_entity(:variable, variable_key, @variables)
97
+ end
98
+
99
+ def attribute_only_conditions?(conditions)
100
+ key = conditions.object_id
101
+ cached = @attribute_conditions_cache[key]
102
+ return cached[1] if cached
103
+
104
+ # Retain the syntax with its identity. Cache classification, never matches.
105
+ @attribute_conditions_cache[key] = [conditions, UnifiedConditions.send(:attribute_only?, conditions)]
106
+ @attribute_conditions_cache[key][1]
107
+ end
108
+
109
+ def retain_normalized(previous, incoming)
110
+ @normalization_mutex, @normalization_slots, @normalized = previous.normalization_snapshot
111
+ { feature: :features, segment: :segments, variable: :variables }.each do |kind, name|
112
+ (incoming[name] || {}).each_key do |key|
113
+ @normalization_slots[kind][key.to_sym] = []
114
+ @normalized[kind].delete(key.to_sym)
115
+ end
116
+ end
117
+ end
118
+
119
+ # Share individual cold slots, never a chain of previous providers.
120
+ def normalization_snapshot
121
+ @normalization_mutex.synchronize do
122
+ { feature: @features, segment: @segments, variable: @variables }.each do |kind, definitions|
123
+ next if @normalization_slots[kind].size == definitions.size
124
+ definitions.each_key { |key| @normalization_slots[kind][key] ||= [] }
125
+ end
126
+ [@normalization_mutex, @normalization_slots.transform_values(&:dup), @normalized.transform_values(&:dup)]
127
+ end
128
+ end
129
+
130
+ def read_entity(kind, key, definitions)
131
+ name = key.to_sym
132
+ cached = @normalized[kind][name]
133
+ return cached if cached
134
+ source = definitions[name] || definitions[key]
135
+ return nil unless source
136
+ errors = []
137
+ value = @normalization_mutex.synchronize do
138
+ slot = @normalization_slots[kind][name] ||= []
139
+ if slot.empty?
140
+ normalized, errors = Normalize.entity(kind, name, source)
141
+ slot << normalized
142
+ end
143
+ @normalized[kind][name] = slot.first
144
+ end
145
+ # Publication precedes diagnostics, and application callbacks never hold the mutex.
146
+ errors.each do |error|
147
+ @diagnostics.error(error[:message], { code: error[:code], error: error[:original_error], details: error[:details] })
148
+ end
149
+ value
125
150
  end
126
151
 
127
152
  # Check if a feature has variations
@@ -153,7 +178,7 @@ module Featurevisor
153
178
  invalid_flags = flags.delete("gimsuy")
154
179
  raise ArgumentError, "invalid regular expression flags: #{invalid_flags}" unless invalid_flags.empty?
155
180
 
156
- regex = Regexp.new(regex_string, ruby_flags)
181
+ regex = Regexp.new(Conditions.normalize_portable_regex(regex_string), ruby_flags)
157
182
  @regex_cache[cache_key] = regex
158
183
  @regex_cache[cache_key]
159
184
  end
@@ -168,11 +193,9 @@ module Featurevisor
168
193
  return false
169
194
  end
170
195
 
171
- get_regex_proc = ->(regex_string, regex_flags) { get_regex(regex_string, regex_flags) }
172
-
173
196
  if conditions.is_a?(Hash) && (conditions[:attribute] || conditions["attribute"])
174
197
  begin
175
- result = Conditions.condition_is_matched(conditions, context, get_regex_proc)
198
+ result = Conditions.condition_is_matched(conditions, context, @get_regex)
176
199
  return result
177
200
  rescue => e
178
201
  @diagnostics.warn(e.message, {
@@ -186,11 +209,11 @@ module Featurevisor
186
209
  end
187
210
 
188
211
  if conditions.is_a?(Hash) && conditions[:and] && conditions[:and].is_a?(Array)
189
- return conditions[:and].all? { |c| all_conditions_are_matched(c, context) }
212
+ return !conditions[:and].empty? && conditions[:and].all? { |c| all_conditions_are_matched(c, context) }
190
213
  end
191
214
 
192
215
  if conditions.is_a?(Hash) && conditions["and"] && conditions["and"].is_a?(Array)
193
- return conditions["and"].all? { |c| all_conditions_are_matched(c, context) }
216
+ return !conditions["and"].empty? && conditions["and"].all? { |c| all_conditions_are_matched(c, context) }
194
217
  end
195
218
 
196
219
  if conditions.is_a?(Hash) && conditions[:or] && conditions[:or].is_a?(Array)
@@ -214,7 +237,7 @@ module Featurevisor
214
237
 
215
238
 
216
239
  if conditions.is_a?(Array)
217
- return conditions.all? { |c| all_conditions_are_matched(c, context) }
240
+ return !conditions.empty? && conditions.all? { |c| all_conditions_are_matched(c, context) }
218
241
  end
219
242
 
220
243
  false
@@ -288,8 +311,9 @@ module Featurevisor
288
311
  # @param traffic [Array<Hash>] Traffic array to search
289
312
  # @param context [Hash] Context to evaluate against
290
313
  # @return [Hash, nil] Matched traffic or nil
291
- def get_matched_traffic(traffic, context)
314
+ def get_matched_traffic(traffic, context, options = nil)
292
315
  traffic.find do |t|
316
+ next t.key?(:conditions) && UnifiedConditions.matches?(t[:conditions], options) if options
293
317
  segments = parse_segments_if_stringified(t[:segments])
294
318
  matched = all_segments_are_matched(segments, context)
295
319
  next false unless matched
@@ -314,7 +338,7 @@ module Featurevisor
314
338
  # @param feature_key [String, Hash] Feature key or feature object
315
339
  # @param context [Hash] Context to evaluate against
316
340
  # @return [Hash] Force result with force and forceIndex
317
- def get_matched_force(feature_key, context)
341
+ def get_matched_force(feature_key, context, options = nil)
318
342
  result = {
319
343
  force: nil,
320
344
  forceIndex: nil
@@ -325,6 +349,14 @@ module Featurevisor
325
349
  return result unless feature && feature[:force]
326
350
 
327
351
  feature[:force].each_with_index do |current_force, i|
352
+ if options
353
+ if current_force.key?(:conditions) && UnifiedConditions.matches?(current_force[:conditions], options)
354
+ result[:force] = current_force
355
+ result[:forceIndex] = i
356
+ break
357
+ end
358
+ next
359
+ end
328
360
  if current_force[:conditions] && all_conditions_are_matched(
329
361
  parse_conditions_if_stringified(current_force[:conditions]), context
330
362
  )
@@ -352,9 +384,10 @@ module Featurevisor
352
384
  return conditions unless conditions.is_a?(String)
353
385
 
354
386
  return conditions if conditions == "*"
387
+ return @conditions_cache[conditions] if @conditions_cache.key?(conditions)
355
388
 
356
389
  begin
357
- JSON.parse(conditions)
390
+ @conditions_cache[conditions] = JSON.parse(conditions)
358
391
  rescue => e
359
392
  @diagnostics.error("Error parsing conditions", {
360
393
  error: e,
@@ -371,11 +404,27 @@ module Featurevisor
371
404
  # @return [Array, Hash, String] Parsed segments
372
405
  def parse_segments_if_stringified(segments)
373
406
  if segments.is_a?(String) && (segments.start_with?("{") || segments.start_with?("["))
374
- return JSON.parse(segments)
407
+ return @segments_cache[segments] if @segments_cache.key?(segments)
408
+
409
+ return @segments_cache[segments] = JSON.parse(segments)
375
410
  end
376
411
 
377
412
  segments
378
413
  end
414
+
415
+ # Cache parsed expressions, never context dependent matching results.
416
+ # A new provider owns a new generation after each datafile update.
417
+ def clear_caches
418
+ @dependency_index = nil
419
+ @normalization_mutex.synchronize do
420
+ @normalized.each_value(&:clear)
421
+ @normalization_slots.each_value(&:clear)
422
+ end
423
+ @regex_cache.clear
424
+ @conditions_cache.clear
425
+ @segments_cache.clear
426
+ @attribute_conditions_cache.clear
427
+ end
379
428
  end
380
429
 
381
430
  private_constant :InstanceEvaluationDataProvider