flagnetic 0.1.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 +7 -0
- data/LICENSE +202 -0
- data/README.md +276 -0
- data/lib/flagnetic/backoff.rb +29 -0
- data/lib/flagnetic/changes.rb +88 -0
- data/lib/flagnetic/client.rb +609 -0
- data/lib/flagnetic/controller_helpers.rb +97 -0
- data/lib/flagnetic/detail.rb +73 -0
- data/lib/flagnetic/eval/evaluation.rb +382 -0
- data/lib/flagnetic/eval/instant.rb +71 -0
- data/lib/flagnetic/eval/murmur3.rb +70 -0
- data/lib/flagnetic/eval/operators.rb +225 -0
- data/lib/flagnetic/eval/regex.rb +647 -0
- data/lib/flagnetic/eval/ruleset.rb +267 -0
- data/lib/flagnetic/eval/semver.rb +80 -0
- data/lib/flagnetic/eval/types.rb +148 -0
- data/lib/flagnetic/eval/unicode_tables.rb +180 -0
- data/lib/flagnetic/eval.rb +38 -0
- data/lib/flagnetic/event_batch.rb +119 -0
- data/lib/flagnetic/exposures.rb +342 -0
- data/lib/flagnetic/fallback_chain.rb +230 -0
- data/lib/flagnetic/generations.rb +50 -0
- data/lib/flagnetic/global.rb +59 -0
- data/lib/flagnetic/local_overrides.rb +65 -0
- data/lib/flagnetic/logging.rb +62 -0
- data/lib/flagnetic/railtie.rb +146 -0
- data/lib/flagnetic/recorder.rb +135 -0
- data/lib/flagnetic/reporter.rb +94 -0
- data/lib/flagnetic/sse_parser.rb +94 -0
- data/lib/flagnetic/store.rb +34 -0
- data/lib/flagnetic/stream.rb +65 -0
- data/lib/flagnetic/summaries.rb +257 -0
- data/lib/flagnetic/testing.rb +160 -0
- data/lib/flagnetic/transport.rb +106 -0
- data/lib/flagnetic/uuid.rb +25 -0
- data/lib/flagnetic/version.rb +16 -0
- data/lib/flagnetic/worker.rb +270 -0
- data/lib/flagnetic.rb +34 -0
- metadata +79 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require_relative "version"
|
|
5
|
+
|
|
6
|
+
module Flagnetic
|
|
7
|
+
# ControllerHelpers builds the context of the current request; the
|
|
8
|
+
# railtie includes it in every controller and makes flagnetic_context a
|
|
9
|
+
# view helper.
|
|
10
|
+
#
|
|
11
|
+
# Flagnetic.client.bool("new_checkout", default: false, context: flagnetic_context)
|
|
12
|
+
#
|
|
13
|
+
# The context holds the configured unit types:
|
|
14
|
+
#
|
|
15
|
+
# - config.flagnetic.user_unit_type: the unit type of the signed-in user,
|
|
16
|
+
# whose ID config.flagnetic.user_id reads (a callable receiving the
|
|
17
|
+
# controller; by default current_user&.id where the controller has
|
|
18
|
+
# current_user, else session[:user_id]). Absent while signed out.
|
|
19
|
+
# - config.flagnetic.anonymous_unit_type: the unit type of a random ID
|
|
20
|
+
# generated on the first request and kept in the cookie named by
|
|
21
|
+
# config.flagnetic.anonymous_cookie. Signing in keeps it, so the context
|
|
22
|
+
# of a signed-in user carries both IDs and an experiment spanning
|
|
23
|
+
# sign-up keeps its variation; call Flagnetic.client.alias
|
|
24
|
+
# with flagnetic_anonymous_id at sign-up to link them for analysis.
|
|
25
|
+
#
|
|
26
|
+
# Neither is set by default: the SDK never invents a unit type. The
|
|
27
|
+
# attributes are the predeclared sdk and sdk_version, platform and
|
|
28
|
+
# app_version when configured, and whatever config.flagnetic.attributes
|
|
29
|
+
# (a callable receiving the controller) returns, which wins over them.
|
|
30
|
+
module ControllerHelpers
|
|
31
|
+
# SIGNED_IN_USER_ID is the default config.flagnetic.user_id.
|
|
32
|
+
SIGNED_IN_USER_ID = lambda { |controller|
|
|
33
|
+
if controller.respond_to?(:current_user, true)
|
|
34
|
+
controller.__send__(:current_user)&.id
|
|
35
|
+
else
|
|
36
|
+
controller.session[:user_id]
|
|
37
|
+
end
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
# ANONYMOUS_ID_FORMAT is what a kept anonymous ID may look like; anything else
|
|
41
|
+
# in the cookie is replaced by a new one.
|
|
42
|
+
ANONYMOUS_ID_FORMAT = /\A[A-Za-z0-9_-]{1,64}\z/
|
|
43
|
+
|
|
44
|
+
def self.included(base)
|
|
45
|
+
base.helper_method(:flagnetic_context, :flagnetic_anonymous_id) if base.respond_to?(:helper_method)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# flagnetic_context returns the context of the current request, built
|
|
49
|
+
# once per request.
|
|
50
|
+
def flagnetic_context
|
|
51
|
+
@flagnetic_context ||= { units: flagnetic_units(flagnetic_config),
|
|
52
|
+
attributes: flagnetic_attributes(flagnetic_config) }.freeze
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# flagnetic_anonymous_id returns the anonymous ID of the current
|
|
56
|
+
# visitor, generating it and setting its cookie on the first request,
|
|
57
|
+
# or nil when no anonymous unit type is configured.
|
|
58
|
+
def flagnetic_anonymous_id
|
|
59
|
+
return @flagnetic_anonymous_id if defined?(@flagnetic_anonymous_id)
|
|
60
|
+
|
|
61
|
+
config = flagnetic_config
|
|
62
|
+
@flagnetic_anonymous_id = (flagnetic_cookie_id(config.anonymous_cookie) if config.anonymous_unit_type)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def flagnetic_config = ::Rails.application.config.flagnetic
|
|
68
|
+
|
|
69
|
+
def flagnetic_units(config)
|
|
70
|
+
units = {}
|
|
71
|
+
units[config.anonymous_unit_type.to_s] = flagnetic_anonymous_id if config.anonymous_unit_type
|
|
72
|
+
if config.user_unit_type
|
|
73
|
+
id = config.user_id.call(self)
|
|
74
|
+
units[config.user_unit_type.to_s] = id.to_s if id.present?
|
|
75
|
+
end
|
|
76
|
+
units
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def flagnetic_attributes(config)
|
|
80
|
+
attributes = { "sdk" => "flagnetic-ruby", "sdk_version" => VERSION }
|
|
81
|
+
attributes["platform"] = config.platform if config.platform
|
|
82
|
+
attributes["app_version"] = config.app_version if config.app_version
|
|
83
|
+
own = config.attributes&.call(self) || {}
|
|
84
|
+
attributes.merge(own.to_h.transform_keys(&:to_s))
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def flagnetic_cookie_id(name)
|
|
88
|
+
jar = request.cookie_jar
|
|
89
|
+
id = jar[name]
|
|
90
|
+
return id if id.is_a?(String) && id.match?(ANONYMOUS_ID_FORMAT)
|
|
91
|
+
|
|
92
|
+
id = SecureRandom.uuid
|
|
93
|
+
jar.permanent[name] = { value: id, httponly: true, same_site: :lax, secure: request.ssl? }
|
|
94
|
+
id
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flagnetic
|
|
4
|
+
# REASONS are the evaluation reasons a Detail carries. The evaluator
|
|
5
|
+
# produces the first eight; the SDK produces local_override and
|
|
6
|
+
# fallback. A getter never serves error: an evaluator error serves the
|
|
7
|
+
# built-in default with reason fallback.
|
|
8
|
+
REASONS = %i[rule rollout experiment override prerequisite_failed error off fallthrough local_override
|
|
9
|
+
fallback].freeze
|
|
10
|
+
|
|
11
|
+
# STAGES are the steps of the fallback chain. built_in: no ruleset was
|
|
12
|
+
# ever received, getters serve built-in defaults. last_good: the held
|
|
13
|
+
# ruleset came from an earlier success, or from the store, and the most
|
|
14
|
+
# recent attempt failed. fresh: the most recent attempt succeeded, a 304
|
|
15
|
+
# included. An offline client is always fresh once its document loads.
|
|
16
|
+
STAGES = %i[built_in last_good fresh].freeze
|
|
17
|
+
|
|
18
|
+
# ERROR_CODES say why a Detail has reason fallback: the evaluator's four,
|
|
19
|
+
# then type_mismatch (the flag's type differs from the getter's),
|
|
20
|
+
# no_ruleset (stage built_in) and general (an internal failure caught at
|
|
21
|
+
# the API boundary).
|
|
22
|
+
ERROR_CODES = %i[flag_not_found server_only prerequisite_cycle inconsistent_ruleset type_mismatch no_ruleset
|
|
23
|
+
general].freeze
|
|
24
|
+
|
|
25
|
+
# Detail is a served value and why it was served.
|
|
26
|
+
#
|
|
27
|
+
# - value: the served value (true or false, a String, a Numeric, or for a
|
|
28
|
+
# JSON flag the parsed, frozen value), or the built-in default as passed
|
|
29
|
+
# with reason fallback.
|
|
30
|
+
# - reason: one of REASONS. variation and rule_id as the evaluator sets
|
|
31
|
+
# them; error_code with reason fallback.
|
|
32
|
+
# - ruleset_version, received_at: the ruleset evaluated against, nil at
|
|
33
|
+
# stage built_in. The age of a last good ruleset is observable through
|
|
34
|
+
# received_at.
|
|
35
|
+
# - stage: one of STAGES, at the moment of the evaluation.
|
|
36
|
+
# - overridden: a platform override decided the value, directly or
|
|
37
|
+
# through a prerequisite. A local override is reason local_override
|
|
38
|
+
# instead.
|
|
39
|
+
# - experiment: an Eval::ExperimentDetail when the value came from a
|
|
40
|
+
# running experiment, an override landing in one included.
|
|
41
|
+
Detail = Struct.new(:value, :reason, :variation, :rule_id, :error_code, :ruleset_version, :received_at, :stage,
|
|
42
|
+
:overridden, :experiment, keyword_init: true) do
|
|
43
|
+
def initialize(overridden: false, **fields)
|
|
44
|
+
super
|
|
45
|
+
freeze
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Status is the state of the fallback chain: the stage, and since when
|
|
50
|
+
# the held ruleset is stale (the time of the first failed attempt since
|
|
51
|
+
# the last success), nil while the most recent attempt succeeded.
|
|
52
|
+
Status = Struct.new(:stage, :stale_since, keyword_init: true) do
|
|
53
|
+
def initialize(**fields)
|
|
54
|
+
super
|
|
55
|
+
freeze
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Error is the base of every error the SDK raises. Getters never raise.
|
|
60
|
+
class Error < StandardError; end
|
|
61
|
+
|
|
62
|
+
# LocalOverridesNotAllowed is raised by LocalOverrides#set on a client
|
|
63
|
+
# constructed without allow_overrides: true.
|
|
64
|
+
class LocalOverridesNotAllowed < Error; end
|
|
65
|
+
|
|
66
|
+
# LocalOverrideRefused is raised by LocalOverrides#set when the held
|
|
67
|
+
# ruleset belongs to a protected environment.
|
|
68
|
+
class LocalOverrideRefused < Error; end
|
|
69
|
+
|
|
70
|
+
# OfflineError is raised by Client#reload when the offline document
|
|
71
|
+
# cannot be read or decoded; the held ruleset stays.
|
|
72
|
+
class OfflineError < Error; end
|
|
73
|
+
end
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flagnetic
|
|
4
|
+
# Eval is the evaluator; see flagnetic/eval.rb.
|
|
5
|
+
module Eval
|
|
6
|
+
# Index holds the flag and segment maps and the conditions of a decoded
|
|
7
|
+
# ruleset, compiled once, and what the ruleset holds that this evaluator
|
|
8
|
+
# does not know.
|
|
9
|
+
class Index
|
|
10
|
+
# invalid_serves marks, by rule index, the experiment serves that
|
|
11
|
+
# break the invariants every experiment serve holds (see
|
|
12
|
+
# Compiler#valid_experiment?), so evaluation reaching one answers
|
|
13
|
+
# inconsistent_ruleset without checking them again.
|
|
14
|
+
CompiledFlag = Struct.new(:flag, :variations, :rules, :invalid_serves)
|
|
15
|
+
# CompiledCondition is a condition prepared once at decode time. error
|
|
16
|
+
# is set when it cannot be evaluated against this document; evaluating
|
|
17
|
+
# it then yields inconsistent_ruleset. unsupported is the construct of
|
|
18
|
+
# an error that comes from an operator or condition kind this
|
|
19
|
+
# evaluator does not know.
|
|
20
|
+
CompiledCondition = Struct.new(:kind, :error, :attribute, :names_unit, :match, :segment, :flag, :variation,
|
|
21
|
+
:unsupported)
|
|
22
|
+
# CompiledSegment is a segment's conditions and the unsupported
|
|
23
|
+
# constructs it holds.
|
|
24
|
+
CompiledSegment = Struct.new(:conditions, :unsupported)
|
|
25
|
+
|
|
26
|
+
attr_reader :flags, :segments, :unsupported
|
|
27
|
+
|
|
28
|
+
def initialize(ruleset)
|
|
29
|
+
# A unit type named as an attribute is a string attribute holding
|
|
30
|
+
# that unit's ID.
|
|
31
|
+
units = ruleset.unit_types.to_h { |u| [u, true] }
|
|
32
|
+
types = ruleset.attributes.to_h { |a| [a.key, a.type] }.merge(units.transform_values { "string" })
|
|
33
|
+
compiler = Compiler.new(types, units)
|
|
34
|
+
@segments = ruleset.segments.to_h { |s| [s.key, compiler.segment(s)] }.freeze
|
|
35
|
+
@flags = {}
|
|
36
|
+
@unsupported = {}
|
|
37
|
+
ruleset.flags.each do |f|
|
|
38
|
+
@flags[f.key] = compiler.flag(f)
|
|
39
|
+
found = compiler.unsupported_in(f, @flags[f.key], @segments)
|
|
40
|
+
@unsupported[f.key] = found.freeze unless found.empty?
|
|
41
|
+
end
|
|
42
|
+
@flags.freeze
|
|
43
|
+
@unsupported.freeze
|
|
44
|
+
freeze
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The condition kinds and serve kinds this evaluator knows.
|
|
49
|
+
CONDITION_KINDS = %w[attribute in_segment not_in_segment prerequisite].freeze
|
|
50
|
+
SERVE_KINDS = %w[variation rollout experiment].freeze
|
|
51
|
+
private_constant :CONDITION_KINDS, :SERVE_KINDS
|
|
52
|
+
|
|
53
|
+
# Compiler compiles the conditions of one ruleset against its declared
|
|
54
|
+
# attribute types and unit types.
|
|
55
|
+
Compiler = Struct.new(:types, :units) do
|
|
56
|
+
def segment(segment)
|
|
57
|
+
found = []
|
|
58
|
+
conditions = segment.conditions.map do |c|
|
|
59
|
+
next add_unsupported(found, compile(c)) if c.kind == "attribute"
|
|
60
|
+
|
|
61
|
+
error = "segment #{segment.key.inspect} has a #{c.kind.inspect} condition"
|
|
62
|
+
unknown = construct(:condition_kind, c.kind) unless CONDITION_KINDS.include?(c.kind)
|
|
63
|
+
add_unsupported(found, invalid(c, error, unknown))
|
|
64
|
+
end
|
|
65
|
+
Index::CompiledSegment.new(conditions.freeze, found.freeze).freeze
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def flag(flag)
|
|
69
|
+
variations = flag.variations.to_h { |v| [v.key, v.value] }.freeze
|
|
70
|
+
rules = flag.rules.map { |r| r.conditions.map { |c| compile(c) }.freeze }.freeze
|
|
71
|
+
invalid_serves = flag.rules.map { |r| r.serve.kind == "experiment" && !valid_experiment?(r.serve, variations) }
|
|
72
|
+
Index::CompiledFlag.new(flag, variations, rules, invalid_serves.freeze).freeze
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# unsupported_in lists the constructs a compiled flag holds that this
|
|
76
|
+
# evaluator does not know, in document order, each once: those of its
|
|
77
|
+
# rules, and of the segments its rules name.
|
|
78
|
+
def unsupported_in(flag, compiled, segments)
|
|
79
|
+
found = []
|
|
80
|
+
flag.rules.each_with_index do |rule, i|
|
|
81
|
+
compiled.rules[i].each do |c|
|
|
82
|
+
add_unsupported(found, c)
|
|
83
|
+
next if c.segment.to_s.empty?
|
|
84
|
+
|
|
85
|
+
segments[c.segment]&.unsupported&.each { |u| found << u unless found.include?(u) }
|
|
86
|
+
end
|
|
87
|
+
kind = rule.serve.kind
|
|
88
|
+
add_construct(found, construct(:serve_kind, kind)) unless SERVE_KINDS.include?(kind)
|
|
89
|
+
end
|
|
90
|
+
found
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
# valid_experiment? reports whether an experiment serve holds what
|
|
96
|
+
# assigning a unit to a variation relies on: an experiment key, a unit
|
|
97
|
+
# type and both salts; a traffic allocation within 0 to 10000; and at
|
|
98
|
+
# least two weights, none negative, each naming a distinct variation
|
|
99
|
+
# of the flag, summing to exactly 10000. Any other serve would assign
|
|
100
|
+
# units to variations the experiment does not compare, or to none, so
|
|
101
|
+
# every evaluator refuses it the same way instead of each assigning
|
|
102
|
+
# its own way.
|
|
103
|
+
def valid_experiment?(serve, variations)
|
|
104
|
+
return false if [serve.experiment, serve.unit_type, serve.inclusion_salt, serve.variation_salt].any?(&:empty?)
|
|
105
|
+
return false unless (0..BUCKETS).cover?(serve.traffic_allocation) && serve.weights.size >= 2
|
|
106
|
+
|
|
107
|
+
named = serve.weights.map(&:variation)
|
|
108
|
+
named.uniq.size == named.size && named.all? { |v| variations.key?(v) } &&
|
|
109
|
+
serve.weights.none? { |w| w.weight.negative? } && serve.weights.sum(&:weight) == BUCKETS
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def add_unsupported(found, compiled)
|
|
113
|
+
add_construct(found, compiled.unsupported)
|
|
114
|
+
compiled
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def add_construct(found, construct)
|
|
118
|
+
found << construct if construct && !found.include?(construct)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# construct is the unsupported construct of a kind with a name; none
|
|
122
|
+
# for a missing name: no server sends that, so it is a defect of the
|
|
123
|
+
# ruleset rather than a reason to upgrade.
|
|
124
|
+
def construct(kind, name)
|
|
125
|
+
Construct.new(kind, name).freeze unless name.empty?
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def invalid(cond, error, unsupported = nil)
|
|
129
|
+
Index::CompiledCondition.new(cond.kind, error, nil, nil, nil, nil, nil, nil, unsupported).freeze
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def compile(cond)
|
|
133
|
+
case cond.kind
|
|
134
|
+
when "attribute" then compile_attribute(cond)
|
|
135
|
+
when "in_segment", "not_in_segment"
|
|
136
|
+
Index::CompiledCondition.new(cond.kind, nil, nil, false, nil, cond.segment).freeze
|
|
137
|
+
when "prerequisite"
|
|
138
|
+
Index::CompiledCondition.new(cond.kind, nil, nil, false, nil, nil, cond.flag, cond.variation).freeze
|
|
139
|
+
else
|
|
140
|
+
invalid(cond, "unknown condition kind #{cond.kind.inspect}", construct(:condition_kind, cond.kind))
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def compile_attribute(cond)
|
|
145
|
+
unless Operators::KNOWN.include?(cond.op)
|
|
146
|
+
return invalid(cond, "unknown operator #{cond.op.inspect}", construct(:operator, cond.op))
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
type = types[cond.attribute]
|
|
150
|
+
return invalid(cond, "attribute #{cond.attribute.inspect} is not declared") unless type
|
|
151
|
+
|
|
152
|
+
match = Operators.compile(type, cond.op, cond.value)
|
|
153
|
+
Index::CompiledCondition.new(cond.kind, nil, cond.attribute, units.key?(cond.attribute), match).freeze
|
|
154
|
+
rescue Operators::Error => e
|
|
155
|
+
invalid(cond, "attribute #{cond.attribute.inspect}: #{e.message}")
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
private_constant :Index, :Compiler
|
|
159
|
+
|
|
160
|
+
# Evaluation is the state of one evaluate or evaluate_all call: the
|
|
161
|
+
# flags on the current prerequisite path, and every flag result
|
|
162
|
+
# computed so far.
|
|
163
|
+
#
|
|
164
|
+
# For one flag and one context:
|
|
165
|
+
#
|
|
166
|
+
# 1. An absent flag is error flag_not_found.
|
|
167
|
+
# 2. A flag that is off serves its off variation, reason off.
|
|
168
|
+
# 3. The first override whose unit type is in the context with an equal
|
|
169
|
+
# unit ID serves its variation, reason override, override_influenced
|
|
170
|
+
# true. Steps 4 and 5 then run in shadow, ignoring the override; when
|
|
171
|
+
# they end in an experiment that includes the unit, the result
|
|
172
|
+
# carries that experiment's detail with override true and the served
|
|
173
|
+
# variation.
|
|
174
|
+
# 4. Flag-level prerequisites in order: each evaluates the prerequisite
|
|
175
|
+
# flag with the same context and holds when its reason is neither off
|
|
176
|
+
# nor error and it serves the required variation. The first failure
|
|
177
|
+
# serves the off variation, reason prerequisite_failed, naming that
|
|
178
|
+
# prerequisite. A flag met again on the current prerequisite path is
|
|
179
|
+
# error prerequisite_cycle. An error in a prerequisite becomes the
|
|
180
|
+
# dependent flag's error.
|
|
181
|
+
# 5. Rules in order, first match wins; a rule's conditions in order,
|
|
182
|
+
# stopping at the first false one. A rollout or experiment whose unit
|
|
183
|
+
# type is absent, or whose unit falls outside it, lets evaluation
|
|
184
|
+
# continue with the next rule.
|
|
185
|
+
# 6. No rule matched: the fallthrough variation, reason fallthrough.
|
|
186
|
+
# 7. A variation, segment, flag or attribute referenced but missing from
|
|
187
|
+
# the document, or a condition that cannot be evaluated, is error
|
|
188
|
+
# inconsistent_ruleset, only when evaluation reaches it.
|
|
189
|
+
class Evaluation
|
|
190
|
+
# Failure carries an error code out of nested condition evaluation.
|
|
191
|
+
Failure = Struct.new(:code)
|
|
192
|
+
private_constant :Failure
|
|
193
|
+
|
|
194
|
+
def initialize(index, context)
|
|
195
|
+
@index = index
|
|
196
|
+
@context = context
|
|
197
|
+
@visiting = {}
|
|
198
|
+
@memo = {}
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# flag evaluates a flag by key. A flag reached again while it is on
|
|
202
|
+
# the current prerequisite path is a cycle.
|
|
203
|
+
def flag(key)
|
|
204
|
+
return @memo[key] if @memo.key?(key)
|
|
205
|
+
|
|
206
|
+
f = @index.flags[key]
|
|
207
|
+
return error(:flag_not_found) unless f
|
|
208
|
+
return error(:prerequisite_cycle).__send__(:of_flag, f.flag) if @visiting.key?(key)
|
|
209
|
+
|
|
210
|
+
@visiting[key] = true
|
|
211
|
+
result = compute(f).__send__(:of_flag, f.flag)
|
|
212
|
+
@visiting.delete(key)
|
|
213
|
+
@memo[key] = result
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
private
|
|
217
|
+
|
|
218
|
+
def error(code, influenced = false) # rubocop:disable Style/OptionalBooleanParameter
|
|
219
|
+
Result.new(reason: :error, error_code: code, override_influenced: influenced)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def compute(cf)
|
|
223
|
+
f = cf.flag
|
|
224
|
+
return serve(cf, f.off_variation, reason: :off) unless f.on
|
|
225
|
+
|
|
226
|
+
f.overrides.each do |o|
|
|
227
|
+
next unless @context.unit(o.unit_type) == o.unit_id
|
|
228
|
+
|
|
229
|
+
r = serve(cf, o.variation, reason: :override, override_influenced: true)
|
|
230
|
+
return r if r.reason == :error
|
|
231
|
+
|
|
232
|
+
shadow = body(cf)
|
|
233
|
+
return r unless shadow.reason == :experiment
|
|
234
|
+
|
|
235
|
+
x = shadow.experiment.to_h.merge(variation: r.variation, override: true)
|
|
236
|
+
return Result.new(**r.to_h, experiment: ExperimentDetail.new(**x))
|
|
237
|
+
end
|
|
238
|
+
body(cf)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# body runs steps 4 to 6: prerequisites, rules and the fallthrough.
|
|
242
|
+
def body(cf)
|
|
243
|
+
f = cf.flag
|
|
244
|
+
influenced = [false]
|
|
245
|
+
|
|
246
|
+
f.prerequisites.each do |p|
|
|
247
|
+
held = prerequisite(p.flag, p.variation, influenced)
|
|
248
|
+
return error(held.code, influenced[0]) if held.is_a?(Failure)
|
|
249
|
+
next if held
|
|
250
|
+
|
|
251
|
+
return serve(cf, f.off_variation, reason: :prerequisite_failed, prerequisite: p.flag,
|
|
252
|
+
override_influenced: influenced[0])
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
f.rules.each_with_index do |rule, i|
|
|
256
|
+
matched = conditions(cf.rules[i], influenced)
|
|
257
|
+
return error(matched.code, influenced[0]) if matched.is_a?(Failure)
|
|
258
|
+
next unless matched
|
|
259
|
+
return error(:inconsistent_ruleset, influenced[0]) if cf.invalid_serves[i]
|
|
260
|
+
|
|
261
|
+
r = serve_rule(cf, rule, influenced[0])
|
|
262
|
+
return r if r
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
serve(cf, f.fallthrough, reason: :fallthrough, override_influenced: influenced[0])
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# serve_rule serves a matching rule, or returns nil when a rollout or
|
|
269
|
+
# experiment leaves the unit out and evaluation continues.
|
|
270
|
+
def serve_rule(cf, rule, influenced)
|
|
271
|
+
s = rule.serve
|
|
272
|
+
case s.kind
|
|
273
|
+
when "variation"
|
|
274
|
+
serve(cf, s.variation, reason: :rule, rule_id: rule.id, override_influenced: influenced)
|
|
275
|
+
when "rollout"
|
|
276
|
+
id = @context.unit(s.unit_type)
|
|
277
|
+
bucket = id && Murmur3.bucket(s.salt, id)
|
|
278
|
+
return nil unless bucket && bucket < s.percentage
|
|
279
|
+
|
|
280
|
+
serve(cf, s.variation, reason: :rollout, rule_id: rule.id, bucket: bucket, override_influenced: influenced)
|
|
281
|
+
when "experiment"
|
|
282
|
+
experiment(cf, rule, influenced)
|
|
283
|
+
else
|
|
284
|
+
error(:inconsistent_ruleset, influenced)
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def experiment(cf, rule, influenced)
|
|
289
|
+
s = rule.serve
|
|
290
|
+
id = @context.unit(s.unit_type)
|
|
291
|
+
return nil unless id
|
|
292
|
+
|
|
293
|
+
inclusion = Murmur3.bucket(s.inclusion_salt, id)
|
|
294
|
+
return nil unless inclusion < s.traffic_allocation
|
|
295
|
+
|
|
296
|
+
variation_bucket = Murmur3.bucket(s.variation_salt, id)
|
|
297
|
+
assigned = assign(s.weights, variation_bucket)
|
|
298
|
+
return error(:inconsistent_ruleset, influenced) unless assigned
|
|
299
|
+
|
|
300
|
+
r = serve(cf, assigned, reason: :experiment, rule_id: rule.id, override_influenced: influenced)
|
|
301
|
+
return r if r.reason == :error
|
|
302
|
+
|
|
303
|
+
Result.new(**r.to_h, experiment: ExperimentDetail.new(
|
|
304
|
+
key: s.experiment, variation: assigned, unit_type: s.unit_type, unit_id: id,
|
|
305
|
+
inclusion_salt: s.inclusion_salt, variation_salt: s.variation_salt,
|
|
306
|
+
inclusion_bucket: inclusion, variation_bucket: variation_bucket, override: influenced
|
|
307
|
+
))
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# assign walks weights in list order; variation i covers
|
|
311
|
+
# [sum(w_0..w_{i-1}), sum(w_0..w_i)).
|
|
312
|
+
def assign(weights, bucket)
|
|
313
|
+
upper = 0
|
|
314
|
+
weights.each do |w|
|
|
315
|
+
upper += w.weight
|
|
316
|
+
return w.variation if bucket < upper
|
|
317
|
+
end
|
|
318
|
+
nil
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# serve builds a result serving a variation of the flag, or
|
|
322
|
+
# inconsistent_ruleset when the variation is missing (step 7).
|
|
323
|
+
def serve(cf, variation, **fields)
|
|
324
|
+
unless cf.variations.key?(variation)
|
|
325
|
+
return error(:inconsistent_ruleset,
|
|
326
|
+
fields.fetch(:override_influenced, false))
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
Result.new(value: cf.variations[variation], variation: variation, **fields)
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# prerequisite evaluates another flag for the same context. It holds
|
|
333
|
+
# when that flag's reason is neither off nor error and it serves the
|
|
334
|
+
# required variation. An error in the prerequisite is a defect of the
|
|
335
|
+
# ruleset and is returned as a Failure, so it surfaces on the
|
|
336
|
+
# dependent flag instead of silently serving its off variation.
|
|
337
|
+
# Influence accumulates into influenced[0].
|
|
338
|
+
def prerequisite(key, variation, influenced)
|
|
339
|
+
f = @index.flags[key]
|
|
340
|
+
return Failure.new(:inconsistent_ruleset) if f.nil? || !f.variations.key?(variation)
|
|
341
|
+
|
|
342
|
+
r = flag(key)
|
|
343
|
+
influenced[0] ||= r.override_influenced
|
|
344
|
+
return Failure.new(r.error_code) if r.reason == :error
|
|
345
|
+
|
|
346
|
+
r.reason != :off && r.variation == variation
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# conditions evaluates an AND-set in order and stops at the first false
|
|
350
|
+
# condition; later conditions are not evaluated and cannot influence
|
|
351
|
+
# the result.
|
|
352
|
+
def conditions(conds, influenced)
|
|
353
|
+
conds.each do |c|
|
|
354
|
+
held = condition(c, influenced)
|
|
355
|
+
return held if held.is_a?(Failure) || !held
|
|
356
|
+
end
|
|
357
|
+
true
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def condition(cond, influenced)
|
|
361
|
+
return Failure.new(:inconsistent_ruleset) if cond.error
|
|
362
|
+
|
|
363
|
+
case cond.kind
|
|
364
|
+
when "attribute"
|
|
365
|
+
value = cond.names_unit ? @context.unit(cond.attribute) : @context.attributes[cond.attribute]
|
|
366
|
+
cond.match.call(value)
|
|
367
|
+
when "in_segment", "not_in_segment"
|
|
368
|
+
segment = @index.segments[cond.segment]
|
|
369
|
+
return Failure.new(:inconsistent_ruleset) unless segment
|
|
370
|
+
|
|
371
|
+
inside = conditions(segment.conditions, influenced)
|
|
372
|
+
return inside if inside.is_a?(Failure)
|
|
373
|
+
|
|
374
|
+
inside == (cond.kind == "in_segment")
|
|
375
|
+
else
|
|
376
|
+
prerequisite(cond.flag, cond.variation, influenced)
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
private_constant :Evaluation
|
|
381
|
+
end
|
|
382
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flagnetic
|
|
4
|
+
module Eval
|
|
5
|
+
# Instant reads dates as integer nanoseconds since the Unix epoch, the
|
|
6
|
+
# way the reference evaluator's time.Parse(time.RFC3339Nano) and
|
|
7
|
+
# time.UnixMilli read them.
|
|
8
|
+
module Instant
|
|
9
|
+
RFC3339 = /\A(\d{4})-(\d{2})-(\d{2})T(\d{1,2}):(\d{2}):(\d{2})(?:[.,](\d+))?(?:(Z)|([+-])(\d{2}):(\d{2}))\z/
|
|
10
|
+
MAX_EPOCH_MILLIS = 2**53
|
|
11
|
+
private_constant :RFC3339, :MAX_EPOCH_MILLIS
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# from_context reads a context value: an RFC 3339 string with an
|
|
16
|
+
# offset, an integral number of Unix epoch milliseconds or a Time.
|
|
17
|
+
def from_context(value)
|
|
18
|
+
case value
|
|
19
|
+
when String then parse_rfc3339(value)
|
|
20
|
+
when Time then (value.to_r * 1_000_000_000).floor
|
|
21
|
+
when true, false, nil then nil
|
|
22
|
+
else
|
|
23
|
+
ms = Values.number(value)
|
|
24
|
+
return nil unless ms && ms == ms.truncate && ms.abs <= MAX_EPOCH_MILLIS
|
|
25
|
+
|
|
26
|
+
ms.to_i * 1_000_000
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# parse_rfc3339 accepts what Go's time.Parse with RFC3339Nano accepts:
|
|
31
|
+
# an upper-case T and Z, a one- or two-digit hour, a fraction after .
|
|
32
|
+
# or , truncated to nanoseconds, and an offset of at most 24:60.
|
|
33
|
+
def parse_rfc3339(str)
|
|
34
|
+
m = str.valid_encoding? && RFC3339.match(str)
|
|
35
|
+
return nil unless m
|
|
36
|
+
|
|
37
|
+
year, month, day, hour, min, sec = m.captures[0, 6].map(&:to_i)
|
|
38
|
+
return nil unless valid_date?(year, month, day) && hour < 24 && min < 60 && sec < 60
|
|
39
|
+
|
|
40
|
+
offset = offset_seconds(m)
|
|
41
|
+
return nil unless offset
|
|
42
|
+
|
|
43
|
+
seconds = Time.utc(year, month, day, hour, min, sec).to_i - offset
|
|
44
|
+
(seconds * 1_000_000_000) + m[7].to_s[0, 9].ljust(9, "0").to_i
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def offset_seconds(match)
|
|
48
|
+
return 0 if match[8]
|
|
49
|
+
|
|
50
|
+
hours = match[10].to_i
|
|
51
|
+
minutes = match[11].to_i
|
|
52
|
+
return nil if hours > 24 || minutes > 60
|
|
53
|
+
|
|
54
|
+
seconds = (hours * 3600) + (minutes * 60)
|
|
55
|
+
match[9] == "-" ? -seconds : seconds
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def valid_date?(year, month, day)
|
|
59
|
+
return false unless month.between?(1, 12) && day >= 1
|
|
60
|
+
|
|
61
|
+
days = [31, leap?(year) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1]
|
|
62
|
+
day <= days
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def leap?(year)
|
|
66
|
+
(year % 4).zero? && (!(year % 100).zero? || (year % 400).zero?)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
private_constant :Instant
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flagnetic
|
|
4
|
+
# Eval is the evaluator; see flagnetic/eval.rb.
|
|
5
|
+
module Eval
|
|
6
|
+
# BUCKETS is the number of buckets a unit is hashed into. A percentage in
|
|
7
|
+
# basis points (0 to 10000) is compared against a bucket directly.
|
|
8
|
+
BUCKETS = 10_000
|
|
9
|
+
|
|
10
|
+
# Murmur3 is MurmurHash3 x86 32-bit with seed 0 over the bytes of a
|
|
11
|
+
# string, read as an unsigned integer. Hashing UTF-16 or reading the
|
|
12
|
+
# result as signed gives different buckets and fails the murmur3 vectors.
|
|
13
|
+
module Murmur3
|
|
14
|
+
C1 = 0xcc9e2d51
|
|
15
|
+
C2 = 0x1b873593
|
|
16
|
+
MASK = 0xffffffff
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# bucket computes the bucket of a unit for a salt:
|
|
21
|
+
#
|
|
22
|
+
# murmur3_32(utf8("{salt}.{unit_id}"), seed 0) mod 10000
|
|
23
|
+
#
|
|
24
|
+
# on the unsigned hash, giving 0 to 9999. A unit is inside
|
|
25
|
+
# percentage p when its bucket is below p. It equals ClickHouse
|
|
26
|
+
# murmurHash3_32(concat(salt, '.', unit_id)) % 10000.
|
|
27
|
+
def bucket(salt, unit_id)
|
|
28
|
+
hash32("#{salt.b}.#{unit_id.b}") % BUCKETS
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def hash32(data)
|
|
32
|
+
bytes = data.b
|
|
33
|
+
h = 0
|
|
34
|
+
blocks = bytes.bytesize / 4
|
|
35
|
+
bytes.unpack("V#{blocks}").each do |k|
|
|
36
|
+
h ^= mix(k)
|
|
37
|
+
h = rotl(h, 13)
|
|
38
|
+
h = ((h * 5) + 0xe6546b64) & MASK
|
|
39
|
+
end
|
|
40
|
+
tail = bytes.byteslice(blocks * 4, 3).bytes
|
|
41
|
+
unless tail.empty?
|
|
42
|
+
k = tail.each_with_index.sum { |b, i| b << (8 * i) }
|
|
43
|
+
h ^= mix(k)
|
|
44
|
+
end
|
|
45
|
+
fmix(h ^ (bytes.bytesize & MASK))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def mix(key)
|
|
49
|
+
k = (key * C1) & MASK
|
|
50
|
+
k = rotl(k, 15)
|
|
51
|
+
(k * C2) & MASK
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def fmix(hash)
|
|
55
|
+
h = hash
|
|
56
|
+
h ^= h >> 16
|
|
57
|
+
h = (h * 0x85ebca6b) & MASK
|
|
58
|
+
h ^= h >> 13
|
|
59
|
+
h = (h * 0xc2b2ae35) & MASK
|
|
60
|
+
h ^ (h >> 16)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def rotl(value, bits)
|
|
64
|
+
((value << bits) | (value >> (32 - bits))) & MASK
|
|
65
|
+
end
|
|
66
|
+
private_class_method :mix, :fmix, :rotl
|
|
67
|
+
end
|
|
68
|
+
private_constant :Murmur3
|
|
69
|
+
end
|
|
70
|
+
end
|