featurevisor 3.1.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.
- checksums.yaml +4 -4
- data/README.md +300 -182
- data/bin/cli.rb +84 -17
- data/bin/commands/assess_distribution.rb +10 -52
- data/bin/commands/benchmark.rb +10 -48
- data/bin/commands/node_cli.rb +20 -0
- data/bin/commands/runtime_project.rb +63 -0
- data/bin/commands/test.rb +735 -260
- data/bin/commands.rb +1 -0
- data/bin/segment_tests.rb +89 -0
- data/bin/test_checks.rb +767 -0
- data/lib/featurevisor/child_instance.rb +15 -2
- data/lib/featurevisor/compare_versions.rb +18 -36
- data/lib/featurevisor/conditions.rb +91 -9
- data/lib/featurevisor/dependencies.rb +150 -0
- data/lib/featurevisor/emitter.rb +4 -0
- data/lib/featurevisor/evaluate.rb +64 -115
- data/lib/featurevisor/evaluation_data_provider.rb +100 -51
- data/lib/featurevisor/evaluation_session.rb +191 -0
- data/lib/featurevisor/events.rb +39 -100
- data/lib/featurevisor/instance.rb +258 -59
- data/lib/featurevisor/modules.rb +6 -46
- data/lib/featurevisor/mutations.rb +213 -0
- data/lib/featurevisor/normalize.rb +105 -0
- data/lib/featurevisor/unified_conditions.rb +79 -0
- data/lib/featurevisor/version.rb +1 -1
- data/lib/featurevisor.rb +7 -0
- metadata +10 -1
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Featurevisor
|
|
6
|
+
class RequirementError < StandardError; end
|
|
7
|
+
class RequirementCycleError < RequirementError; end
|
|
8
|
+
|
|
9
|
+
# One evaluation owns its prerequisite stack and completed prerequisite values.
|
|
10
|
+
class EvaluationSession
|
|
11
|
+
def initialize
|
|
12
|
+
@root = nil
|
|
13
|
+
@root_current = nil
|
|
14
|
+
@active = nil
|
|
15
|
+
@memo = nil
|
|
16
|
+
@sticky_inputs = nil
|
|
17
|
+
@segments = nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def dependency_of
|
|
21
|
+
@active ? @active.last&.dig(:current) : @root_current
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def update_current(options)
|
|
25
|
+
current = dependency_of
|
|
26
|
+
return if current && current[:type] == options[:type] &&
|
|
27
|
+
current[:feature_key] == options[:feature_key] && current[:variable_key] == options[:variable_key]
|
|
28
|
+
|
|
29
|
+
identity = identity_of(options)
|
|
30
|
+
if @active
|
|
31
|
+
@active.last[:current] = identity
|
|
32
|
+
else
|
|
33
|
+
@root_current = identity
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def run(options, prerequisite: false)
|
|
38
|
+
# A root needs its original and redirected identities, but no dependency
|
|
39
|
+
# stack, memo or retained sticky maps until a handoff is actually reached.
|
|
40
|
+
if !@root && !@active && !prerequisite
|
|
41
|
+
@root = @root_current = identity_of(options)
|
|
42
|
+
begin
|
|
43
|
+
return yield
|
|
44
|
+
ensure
|
|
45
|
+
clear
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
@active ||= @root ? [{ identity: @root, current: @root_current }] : []
|
|
50
|
+
identity = identity_of(options)
|
|
51
|
+
raise RequirementCycleError, "Circular evaluation requirement" if @active.any? { |frame| frame[:identity] == identity || frame[:current] == identity }
|
|
52
|
+
raise RequirementError, "Evaluation dependency depth exceeded" if @active.length + (@segments&.length || 0) > 256
|
|
53
|
+
|
|
54
|
+
requester = dependency_of
|
|
55
|
+
key = prerequisite ? fingerprint(identity, requester, options) : nil
|
|
56
|
+
borrowed = options[:dependency_purpose] == "composition"
|
|
57
|
+
return @memo[key] if key && @memo&.key?(key)
|
|
58
|
+
|
|
59
|
+
@active << { identity: identity, current: identity }
|
|
60
|
+
begin
|
|
61
|
+
result = yield
|
|
62
|
+
if prerequisite && options[:__featurevisor_project_variable]
|
|
63
|
+
result = options[:__featurevisor_project_variable].call(result)
|
|
64
|
+
end
|
|
65
|
+
if key && key == fingerprint(identity, requester, options)
|
|
66
|
+
minimal = minimal_result(result, borrowed)
|
|
67
|
+
(@memo ||= {})[key] = borrowed ? minimal : duplicate(minimal) if minimal
|
|
68
|
+
end
|
|
69
|
+
result
|
|
70
|
+
rescue => e
|
|
71
|
+
@pending_error = { identity: identity, requester: requester, key: key }
|
|
72
|
+
raise
|
|
73
|
+
ensure
|
|
74
|
+
@active.pop
|
|
75
|
+
clear if @active.empty?
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def segment(key)
|
|
80
|
+
@segments ||= []
|
|
81
|
+
raise RequirementCycleError, "Circular dependency" if @segments.include?(key)
|
|
82
|
+
active_length = @active ? @active.length : (@root ? 1 : 0)
|
|
83
|
+
raise RequirementError, "Evaluation dependency depth exceeded" if @segments.length + [active_length - 1, 0].max >= 256
|
|
84
|
+
@segments << key
|
|
85
|
+
begin
|
|
86
|
+
yield
|
|
87
|
+
ensure
|
|
88
|
+
@segments.pop
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def remember(options, result)
|
|
93
|
+
return unless options[:__featurevisor_prerequisite]
|
|
94
|
+
|
|
95
|
+
identity = identity_of(options)
|
|
96
|
+
pending = @pending_error
|
|
97
|
+
@pending_error = nil
|
|
98
|
+
return unless pending && pending[:identity] == identity && pending[:requester] == dependency_of
|
|
99
|
+
|
|
100
|
+
key = fingerprint(identity, dependency_of, options)
|
|
101
|
+
return unless key && key == pending[:key]
|
|
102
|
+
borrowed = options[:dependency_purpose] == "composition"
|
|
103
|
+
minimal = minimal_result(result, borrowed)
|
|
104
|
+
(@memo ||= {})[key] = borrowed ? minimal : duplicate(minimal) if minimal
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def clear
|
|
110
|
+
@root = @root_current = nil
|
|
111
|
+
@active&.clear
|
|
112
|
+
@memo&.clear
|
|
113
|
+
@sticky_inputs&.clear
|
|
114
|
+
@segments&.clear
|
|
115
|
+
@active = nil
|
|
116
|
+
@pending_error = nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def identity_of(options)
|
|
120
|
+
identity = {}
|
|
121
|
+
identity[:type] = options[:type] unless options[:type].nil?
|
|
122
|
+
identity[:feature_key] = options[:feature_key] unless options[:feature_key].nil?
|
|
123
|
+
identity[:variable_key] = options[:variable_key] unless options[:variable_key].nil?
|
|
124
|
+
identity
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def fingerprint(identity, requester, options)
|
|
128
|
+
@sticky_inputs ||= {}
|
|
129
|
+
# Keep referenced maps alive until the root ends so object_id cannot be reused.
|
|
130
|
+
[options[:sticky], options[:sticky_variables]].each do |input|
|
|
131
|
+
@sticky_inputs[input.object_id] = input if input
|
|
132
|
+
end
|
|
133
|
+
JSON.generate(canonical([
|
|
134
|
+
identity, requester, options[:dependency_purpose], options[:context], options[:sticky]&.object_id, options[:sticky_variables]&.object_id,
|
|
135
|
+
options[:__featurevisor_generation]&.call,
|
|
136
|
+
options[:__featurevisor_child_generation]&.call,
|
|
137
|
+
options[:datafile]&.object_id
|
|
138
|
+
]))
|
|
139
|
+
rescue ArgumentError, TypeError, JSON::GeneratorError
|
|
140
|
+
nil
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def canonical(value, ancestors = [])
|
|
144
|
+
raise ArgumentError, "Too deep" if ancestors.length > 64
|
|
145
|
+
|
|
146
|
+
case value
|
|
147
|
+
when NilClass, TrueClass, FalseClass, String, Integer
|
|
148
|
+
value
|
|
149
|
+
when Float
|
|
150
|
+
raise ArgumentError, "Nonfinite number" unless value.finite?
|
|
151
|
+
value
|
|
152
|
+
when Array
|
|
153
|
+
raise ArgumentError, "Circular value" if ancestors.include?(value.object_id)
|
|
154
|
+
["array", value.map { |entry| canonical(entry, ancestors + [value.object_id]) }]
|
|
155
|
+
when Hash
|
|
156
|
+
raise ArgumentError, "Circular value" if ancestors.include?(value.object_id)
|
|
157
|
+
keys = value.keys
|
|
158
|
+
raise ArgumentError, "Unsupported key" unless keys.all? { |key| key.is_a?(String) || key.is_a?(Symbol) }
|
|
159
|
+
pairs = keys.sort_by { |key| [key.to_s, key.is_a?(String) ? 0 : 1] }.map do |key|
|
|
160
|
+
[key.is_a?(String) ? "string" : "symbol", key.to_s,
|
|
161
|
+
canonical(value[key], ancestors + [value.object_id])]
|
|
162
|
+
end
|
|
163
|
+
["object", pairs]
|
|
164
|
+
else
|
|
165
|
+
raise ArgumentError, "Unsupported value"
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def minimal_result(result, borrowed = false)
|
|
170
|
+
return nil unless result.is_a?(Hash)
|
|
171
|
+
|
|
172
|
+
keys = %i[type feature_key variable_key reason enabled variation_value variable_value __featurevisor_decoded_json]
|
|
173
|
+
value = result.select { |key, _| keys.include?(key) }
|
|
174
|
+
value[:variation] = { value: result[:variation][:value] } if result[:variation].is_a?(Hash)
|
|
175
|
+
value[:variable_schema] = { type: result[:variable_schema][:type] } if result[:variable_schema].is_a?(Hash)
|
|
176
|
+
if result[:variable].is_a?(Hash)
|
|
177
|
+
value[:variable] = { type: result[:variable][:type] }
|
|
178
|
+
value[:variable][:compose] = true if result[:variable][:compose]
|
|
179
|
+
end
|
|
180
|
+
return value if borrowed
|
|
181
|
+
canonical(value)
|
|
182
|
+
duplicate(value)
|
|
183
|
+
rescue ArgumentError, TypeError
|
|
184
|
+
nil
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def duplicate(value)
|
|
188
|
+
Marshal.load(Marshal.dump(value))
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
data/lib/featurevisor/events.rb
CHANGED
|
@@ -33,7 +33,7 @@ module Featurevisor
|
|
|
33
33
|
# @param previous_reader [InstanceEvaluationDataProvider] Previous datafile
|
|
34
34
|
# @param new_reader [InstanceEvaluationDataProvider] New datafile
|
|
35
35
|
# @return [Hash] Event parameters
|
|
36
|
-
def self.get_params_for_datafile_set_event(previous_reader, new_reader, replace = false)
|
|
36
|
+
def self.get_params_for_datafile_set_event(previous_reader, new_reader, replace = false, incoming = nil)
|
|
37
37
|
previous_revision = previous_reader.get_revision
|
|
38
38
|
previous_feature_keys = previous_reader.get_feature_keys
|
|
39
39
|
|
|
@@ -42,6 +42,17 @@ module Featurevisor
|
|
|
42
42
|
previous_variable_keys = previous_reader.get_variable_keys
|
|
43
43
|
new_variable_keys = new_reader.get_variable_keys
|
|
44
44
|
|
|
45
|
+
if previous_feature_keys.empty? && previous_variable_keys.empty? && previous_reader.get_segment_keys.empty?
|
|
46
|
+
return {
|
|
47
|
+
revision: new_revision,
|
|
48
|
+
previousRevision: previous_revision,
|
|
49
|
+
revisionChanged: previous_revision != new_revision,
|
|
50
|
+
features: new_feature_keys,
|
|
51
|
+
variables: new_variable_keys,
|
|
52
|
+
replaced: replace
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
|
|
45
56
|
# results
|
|
46
57
|
removed_features = []
|
|
47
58
|
changed_features = []
|
|
@@ -49,18 +60,20 @@ module Featurevisor
|
|
|
49
60
|
|
|
50
61
|
# checking against existing datafile
|
|
51
62
|
previous_feature_keys.each do |previous_feature_key|
|
|
52
|
-
if !
|
|
63
|
+
if !new_reader.features.key?(previous_feature_key)
|
|
53
64
|
# feature was removed in new datafile
|
|
54
65
|
removed_features << previous_feature_key
|
|
55
66
|
next
|
|
56
67
|
end
|
|
57
68
|
|
|
58
69
|
# feature exists in both datafiles, check if it was changed
|
|
59
|
-
previous_feature = previous_reader.
|
|
60
|
-
new_feature = new_reader.
|
|
70
|
+
previous_feature = previous_reader.features[previous_feature_key]
|
|
71
|
+
new_feature = new_reader.features[previous_feature_key]
|
|
61
72
|
|
|
62
73
|
if previous_feature && new_feature &&
|
|
63
|
-
(previous_feature[:hash].nil? ||
|
|
74
|
+
(previous_feature[:hash].nil? || previous_feature[:hash].empty? ||
|
|
75
|
+
new_feature[:hash].nil? || new_feature[:hash].empty? ||
|
|
76
|
+
previous_feature[:hash] != new_feature[:hash])
|
|
64
77
|
# feature was changed in new datafile
|
|
65
78
|
changed_features << previous_feature_key
|
|
66
79
|
end
|
|
@@ -68,7 +81,7 @@ module Featurevisor
|
|
|
68
81
|
|
|
69
82
|
# checking against new datafile
|
|
70
83
|
new_feature_keys.each do |new_feature_key|
|
|
71
|
-
if !
|
|
84
|
+
if !previous_reader.features.key?(new_feature_key)
|
|
72
85
|
# feature was added in new datafile
|
|
73
86
|
added_features << new_feature_key
|
|
74
87
|
end
|
|
@@ -77,41 +90,33 @@ module Featurevisor
|
|
|
77
90
|
# combine all affected feature keys
|
|
78
91
|
all_affected_features = (removed_features + changed_features + added_features).uniq
|
|
79
92
|
all_affected_variables = (previous_variable_keys + new_variable_keys).uniq.select do |key|
|
|
80
|
-
previous = previous_reader.
|
|
81
|
-
current = new_reader.
|
|
82
|
-
previous.nil? || current.nil? ||
|
|
93
|
+
previous = previous_reader.variables[key]
|
|
94
|
+
current = new_reader.variables[key]
|
|
95
|
+
previous.nil? || current.nil? ||
|
|
96
|
+
previous[:hash].nil? || previous[:hash].empty? || current[:hash].nil? || current[:hash].empty? ||
|
|
97
|
+
previous[:hash] != current[:hash]
|
|
83
98
|
end
|
|
84
99
|
|
|
85
100
|
|
|
101
|
+
new_datafile = new_reader.get_datafile
|
|
86
102
|
changed_segments = (previous_reader.get_segment_keys + new_reader.get_segment_keys).uniq.select do |key|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
loop do
|
|
91
|
-
before = all_affected_features.length
|
|
92
|
-
feature_keys.each do |key|
|
|
93
|
-
next if all_affected_features.include?(key)
|
|
94
|
-
|
|
95
|
-
candidates = [previous_reader.get_feature(key), new_reader.get_feature(key)].compact
|
|
96
|
-
all_affected_features << key if candidates.any? do |feature|
|
|
97
|
-
dependency = feature_dependencies(feature)
|
|
98
|
-
!(dependency[:segments].map(&:to_s) & changed_segments).empty? ||
|
|
99
|
-
!(dependency[:features].map(&:to_s) & all_affected_features.map(&:to_s)).empty?
|
|
100
|
-
end
|
|
101
|
-
end
|
|
102
|
-
break if before == all_affected_features.length
|
|
103
|
+
previous = previous_reader.segments[key]
|
|
104
|
+
current = new_reader.segments[key]
|
|
105
|
+
!previous.equal?(current) && previous != current
|
|
103
106
|
end
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
candidates = [previous_reader.get_global_variable(key), new_reader.get_global_variable(key)].compact
|
|
109
|
-
all_affected_variables << key if candidates.any? do |variable|
|
|
110
|
-
dependency = global_variable_dependencies(variable)
|
|
111
|
-
!(dependency[:segments].map(&:to_s) & changed_segments).empty? ||
|
|
112
|
-
!(dependency[:features].map(&:to_s) & all_affected_features.map(&:to_s)).empty?
|
|
107
|
+
index = previous_reader.dependency_index || Dependencies.create_index(previous_reader.get_datafile)
|
|
108
|
+
if replace || incoming.nil?
|
|
109
|
+
incoming = Dependencies::COLLECTIONS.to_h do |name, _|
|
|
110
|
+
[name, (previous_reader.public_send(name).keys + new_datafile[name].keys).uniq.to_h { |key| [key, true] }]
|
|
113
111
|
end
|
|
114
112
|
end
|
|
113
|
+
Dependencies.add_changes(all_affected_features, all_affected_variables, changed_segments,
|
|
114
|
+
index)
|
|
115
|
+
index.update(new_datafile, incoming, previous_reader.get_datafile)
|
|
116
|
+
index.reorder(new_datafile) if replace
|
|
117
|
+
Dependencies.add_changes(all_affected_features, all_affected_variables, changed_segments,
|
|
118
|
+
index)
|
|
119
|
+
new_reader.dependency_index = index
|
|
115
120
|
|
|
116
121
|
{
|
|
117
122
|
revision: new_revision,
|
|
@@ -123,71 +128,5 @@ module Featurevisor
|
|
|
123
128
|
}
|
|
124
129
|
end
|
|
125
130
|
|
|
126
|
-
def self.required_feature_keys(values)
|
|
127
|
-
items = values.is_a?(Array) ? values : [values].compact
|
|
128
|
-
items.filter_map do |required|
|
|
129
|
-
required.is_a?(String) ? required : (required[:feature] || required[:key])
|
|
130
|
-
end
|
|
131
|
-
end
|
|
132
|
-
private_class_method :required_feature_keys
|
|
133
|
-
|
|
134
|
-
def self.segment_keys(value)
|
|
135
|
-
return [] if value.nil? || value == "*"
|
|
136
|
-
if value.is_a?(String)
|
|
137
|
-
if value.start_with?("{", "[")
|
|
138
|
-
begin
|
|
139
|
-
return segment_keys(JSON.parse(value, symbolize_names: true))
|
|
140
|
-
rescue JSON::ParserError
|
|
141
|
-
return []
|
|
142
|
-
end
|
|
143
|
-
end
|
|
144
|
-
return [value]
|
|
145
|
-
end
|
|
146
|
-
return value.flat_map { |item| segment_keys(item) }.uniq if value.is_a?(Array)
|
|
147
|
-
return [] unless value.is_a?(Hash)
|
|
148
|
-
|
|
149
|
-
%i[and or not].flat_map { |key| segment_keys(value[key] || value[key.to_s]) }.uniq
|
|
150
|
-
end
|
|
151
|
-
private_class_method :segment_keys
|
|
152
|
-
|
|
153
|
-
def self.override_dependencies(groups)
|
|
154
|
-
overrides = groups.is_a?(Hash) ? groups.values.flatten : []
|
|
155
|
-
{
|
|
156
|
-
segments: overrides.flat_map { |override| segment_keys(override[:segments]) }.uniq,
|
|
157
|
-
features: overrides.flat_map { |override| required_feature_keys(override[:requiredFeatures]) }.uniq
|
|
158
|
-
}
|
|
159
|
-
end
|
|
160
|
-
private_class_method :override_dependencies
|
|
161
|
-
|
|
162
|
-
def self.feature_dependencies(feature)
|
|
163
|
-
requirements = feature.key?(:requiredFeatures) ? feature[:requiredFeatures] : feature[:required]
|
|
164
|
-
segments = []
|
|
165
|
-
features = required_feature_keys(requirements)
|
|
166
|
-
Array(feature[:traffic]).each do |traffic|
|
|
167
|
-
segments.concat(segment_keys(traffic[:segments]))
|
|
168
|
-
nested = override_dependencies(traffic[:variableOverrides])
|
|
169
|
-
segments.concat(nested[:segments])
|
|
170
|
-
features.concat(nested[:features])
|
|
171
|
-
end
|
|
172
|
-
Array(feature[:force]).each { |force| segments.concat(segment_keys(force[:segments])) }
|
|
173
|
-
Array(feature[:variations]).each do |variation|
|
|
174
|
-
nested = override_dependencies(variation[:variableOverrides])
|
|
175
|
-
segments.concat(nested[:segments])
|
|
176
|
-
features.concat(nested[:features])
|
|
177
|
-
end
|
|
178
|
-
{ segments: segments.uniq, features: features.uniq }
|
|
179
|
-
end
|
|
180
|
-
private_class_method :feature_dependencies
|
|
181
|
-
|
|
182
|
-
def self.global_variable_dependencies(variable)
|
|
183
|
-
segments = []
|
|
184
|
-
features = required_feature_keys(variable[:requiredFeatures])
|
|
185
|
-
Array(variable[:overrides]).each do |override|
|
|
186
|
-
segments.concat(segment_keys(override[:segments]))
|
|
187
|
-
features.concat(required_feature_keys(override[:requiredFeatures]))
|
|
188
|
-
end
|
|
189
|
-
{ segments: segments.uniq, features: features.uniq }
|
|
190
|
-
end
|
|
191
|
-
private_class_method :global_variable_dependencies
|
|
192
131
|
end
|
|
193
132
|
end
|