flipper 1.3.2 → 1.4.1
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/.github/workflows/ci.yml +9 -6
- data/.github/workflows/examples.yml +5 -4
- data/.github/workflows/release.yml +54 -0
- data/.superset/config.json +4 -0
- data/CLAUDE.md +93 -0
- data/Gemfile +6 -2
- data/README.md +4 -3
- data/examples/cloud/backoff_policy.rb +1 -1
- data/examples/cloud/poll_interval/README.md +111 -0
- data/examples/cloud/poll_interval/client.rb +108 -0
- data/examples/cloud/poll_interval/server.rb +98 -0
- data/examples/expressions.rb +35 -11
- data/lib/flipper/adapter.rb +17 -1
- data/lib/flipper/adapters/actor_limit.rb +27 -1
- data/lib/flipper/adapters/cache_base.rb +21 -3
- data/lib/flipper/adapters/dual_write.rb +6 -2
- data/lib/flipper/adapters/failover.rb +9 -3
- data/lib/flipper/adapters/failsafe.rb +2 -2
- data/lib/flipper/adapters/http/client.rb +15 -4
- data/lib/flipper/adapters/http/error.rb +1 -1
- data/lib/flipper/adapters/http.rb +39 -4
- data/lib/flipper/adapters/instrumented.rb +2 -2
- data/lib/flipper/adapters/memoizable.rb +3 -3
- data/lib/flipper/adapters/memory.rb +1 -1
- data/lib/flipper/adapters/poll.rb +15 -0
- data/lib/flipper/adapters/pstore.rb +1 -1
- data/lib/flipper/adapters/strict.rb +30 -0
- data/lib/flipper/adapters/sync/feature_synchronizer.rb +5 -1
- data/lib/flipper/adapters/sync/synchronizer.rb +13 -5
- data/lib/flipper/adapters/sync.rb +7 -3
- data/lib/flipper/cli.rb +51 -0
- data/lib/flipper/cloud/configuration.rb +14 -6
- data/lib/flipper/cloud/dsl.rb +2 -2
- data/lib/flipper/cloud/middleware.rb +1 -1
- data/lib/flipper/cloud/migrate.rb +71 -0
- data/lib/flipper/cloud/telemetry/backoff_policy.rb +6 -3
- data/lib/flipper/cloud/telemetry/submitter.rb +3 -1
- data/lib/flipper/cloud/telemetry.rb +3 -3
- data/lib/flipper/cloud.rb +1 -0
- data/lib/flipper/dsl.rb +1 -1
- data/lib/flipper/export.rb +0 -2
- data/lib/flipper/expressions/all.rb +0 -2
- data/lib/flipper/expressions/feature_enabled.rb +34 -0
- data/lib/flipper/expressions/time.rb +8 -1
- data/lib/flipper/feature.rb +8 -1
- data/lib/flipper/gate.rb +1 -1
- data/lib/flipper/gates/expression.rb +2 -2
- data/lib/flipper/instrumentation/log_subscriber.rb +1 -2
- data/lib/flipper/instrumentation/statsd.rb +4 -2
- data/lib/flipper/instrumentation/subscriber.rb +0 -4
- data/lib/flipper/metadata.rb +1 -0
- data/lib/flipper/poller.rb +54 -11
- data/lib/flipper/version.rb +1 -1
- data/lib/flipper.rb +17 -1
- data/lib/generators/flipper/setup_generator.rb +5 -0
- data/lib/generators/flipper/templates/initializer.rb +45 -0
- data/spec/flipper/adapter_spec.rb +20 -0
- data/spec/flipper/adapters/actor_limit_spec.rb +55 -0
- data/spec/flipper/adapters/dual_write_spec.rb +13 -0
- data/spec/flipper/adapters/failover_spec.rb +12 -0
- data/spec/flipper/adapters/http_spec.rb +241 -0
- data/spec/flipper/adapters/poll_spec.rb +41 -0
- data/spec/flipper/adapters/strict_spec.rb +62 -4
- data/spec/flipper/adapters/sync/feature_synchronizer_spec.rb +12 -0
- data/spec/flipper/adapters/sync/synchronizer_spec.rb +87 -0
- data/spec/flipper/adapters/sync_spec.rb +13 -0
- data/spec/flipper/cli_spec.rb +51 -0
- data/spec/flipper/cloud/configuration_spec.rb +6 -0
- data/spec/flipper/cloud/dsl_spec.rb +11 -3
- data/spec/flipper/cloud/middleware_spec.rb +34 -16
- data/spec/flipper/cloud/migrate_spec.rb +160 -0
- data/spec/flipper/cloud/telemetry/backoff_policy_spec.rb +3 -3
- data/spec/flipper/cloud/telemetry/submitter_spec.rb +4 -4
- data/spec/flipper/cloud/telemetry_spec.rb +6 -6
- data/spec/flipper/cloud_spec.rb +9 -4
- data/spec/flipper/dsl_spec.rb +0 -3
- data/spec/flipper/engine_spec.rb +3 -2
- data/spec/flipper/expressions/time_spec.rb +16 -0
- data/spec/flipper/feature_spec.rb +22 -11
- data/spec/flipper/gates/expression_spec.rb +82 -0
- data/spec/flipper/instrumentation/log_subscriber_spec.rb +1 -0
- data/spec/flipper/instrumentation/statsd_subscriber_spec.rb +1 -1
- data/spec/flipper/middleware/memoizer_spec.rb +41 -11
- data/spec/flipper/model/active_record_spec.rb +11 -0
- data/spec/flipper/poller_spec.rb +347 -4
- data/spec/flipper_integration_spec.rb +133 -0
- data/spec/flipper_spec.rb +7 -2
- data/spec/spec_helper.rb +15 -5
- data/test_rails/generators/flipper/setup_generator_test.rb +5 -0
- data/test_rails/generators/flipper/update_generator_test.rb +1 -1
- data/test_rails/helper.rb +3 -0
- metadata +17 -111
- data/lib/flipper/expressions/duration.rb +0 -28
- data/spec/flipper/expressions/duration_spec.rb +0 -43
data/lib/flipper/adapter.rb
CHANGED
|
@@ -31,7 +31,7 @@ module Flipper
|
|
|
31
31
|
# Public: Get all features and gate values in one call. Defaults to one call
|
|
32
32
|
# to features and another to get_multi. Feel free to override per adapter to
|
|
33
33
|
# make this more efficient.
|
|
34
|
-
def get_all
|
|
34
|
+
def get_all(**kwargs)
|
|
35
35
|
instances = features.map { |key| Flipper::Feature.new(key, self) }
|
|
36
36
|
get_multi(instances)
|
|
37
37
|
end
|
|
@@ -73,6 +73,22 @@ module Flipper
|
|
|
73
73
|
def name
|
|
74
74
|
@name ||= self.class.name.split('::').last.split(/(?=[A-Z])/).join('_').downcase.to_sym
|
|
75
75
|
end
|
|
76
|
+
|
|
77
|
+
# Public: Returns a string representation of the adapter stack for debugging.
|
|
78
|
+
# Shows the full chain of wrapped adapters.
|
|
79
|
+
#
|
|
80
|
+
# Examples:
|
|
81
|
+
# "memoizable -> active_support_cache_store -> active_record"
|
|
82
|
+
# "memoizable -> failover(primary: redis, secondary: memory)"
|
|
83
|
+
#
|
|
84
|
+
# Returns a String.
|
|
85
|
+
def adapter_stack
|
|
86
|
+
if respond_to?(:adapter) && adapter
|
|
87
|
+
"#{name} -> #{adapter.adapter_stack}"
|
|
88
|
+
else
|
|
89
|
+
name.to_s
|
|
90
|
+
end
|
|
91
|
+
end
|
|
76
92
|
end
|
|
77
93
|
end
|
|
78
94
|
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require "flipper/adapters/wrapper"
|
|
2
|
+
|
|
1
3
|
module Flipper
|
|
2
4
|
module Adapters
|
|
3
5
|
class ActorLimit < Wrapper
|
|
@@ -5,13 +7,37 @@ module Flipper
|
|
|
5
7
|
|
|
6
8
|
attr_reader :limit
|
|
7
9
|
|
|
10
|
+
class << self
|
|
11
|
+
# Returns whether sync mode is enabled for the current thread.
|
|
12
|
+
# When sync mode is enabled, actor limits are not enforced,
|
|
13
|
+
# allowing sync operations to bring local state in line with
|
|
14
|
+
# remote state regardless of limits.
|
|
15
|
+
def sync_mode
|
|
16
|
+
Thread.current[:flipper_actor_limit_sync_mode]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def sync_mode=(value)
|
|
20
|
+
Thread.current[:flipper_actor_limit_sync_mode] = value
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Executes a block with sync mode enabled. Actor limits will
|
|
24
|
+
# not be enforced within the block.
|
|
25
|
+
def with_sync_mode
|
|
26
|
+
old_value = sync_mode
|
|
27
|
+
self.sync_mode = true
|
|
28
|
+
yield
|
|
29
|
+
ensure
|
|
30
|
+
self.sync_mode = old_value
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
8
34
|
def initialize(adapter, limit = 100)
|
|
9
35
|
super(adapter)
|
|
10
36
|
@limit = limit
|
|
11
37
|
end
|
|
12
38
|
|
|
13
39
|
def enable(feature, gate, resource)
|
|
14
|
-
if gate.is_a?(Flipper::Gates::Actor) && over_limit?(feature)
|
|
40
|
+
if gate.is_a?(Flipper::Gates::Actor) && !self.class.sync_mode && over_limit?(feature)
|
|
15
41
|
raise LimitExceeded, "Actor limit of #{@limit} exceeded for feature #{feature.key}. See https://www.flippercloud.io/docs/features/actors#limitations"
|
|
16
42
|
else
|
|
17
43
|
super
|
|
@@ -17,6 +17,9 @@ module Flipper
|
|
|
17
17
|
# Public: The cache key where the set of known features is cached.
|
|
18
18
|
attr_reader :features_cache_key
|
|
19
19
|
|
|
20
|
+
# Public: The cache key where the set of all features with gates is cached.
|
|
21
|
+
attr_reader :get_all_cache_key
|
|
22
|
+
|
|
20
23
|
# Public: Alias expires_in to ttl for compatibility.
|
|
21
24
|
alias_method :expires_in, :ttl
|
|
22
25
|
|
|
@@ -29,16 +32,24 @@ module Flipper
|
|
|
29
32
|
@namespace = "flipper/#{@cache_version}"
|
|
30
33
|
@namespace = @namespace.prepend(prefix) if prefix
|
|
31
34
|
@features_cache_key = "#{@namespace}/features"
|
|
35
|
+
@get_all_cache_key = "#{@namespace}/get_all"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Public: Expire the cache for the set of all features with gates.
|
|
39
|
+
def expire_get_all_cache
|
|
40
|
+
cache_delete @get_all_cache_key
|
|
32
41
|
end
|
|
33
42
|
|
|
34
43
|
# Public: Expire the cache for the set of known feature names.
|
|
35
44
|
def expire_features_cache
|
|
36
45
|
cache_delete @features_cache_key
|
|
46
|
+
expire_get_all_cache
|
|
37
47
|
end
|
|
38
48
|
|
|
39
49
|
# Public: Expire the cache for a given feature.
|
|
40
50
|
def expire_feature_cache(key)
|
|
41
51
|
cache_delete feature_cache_key(key)
|
|
52
|
+
expire_get_all_cache
|
|
42
53
|
end
|
|
43
54
|
|
|
44
55
|
# Public
|
|
@@ -79,9 +90,12 @@ module Flipper
|
|
|
79
90
|
end
|
|
80
91
|
|
|
81
92
|
# Public
|
|
82
|
-
def get_all
|
|
83
|
-
|
|
84
|
-
|
|
93
|
+
def get_all(**kwargs)
|
|
94
|
+
cache_fetch(@get_all_cache_key) {
|
|
95
|
+
result = read_all_features(**kwargs)
|
|
96
|
+
cache_write @features_cache_key, result.keys.to_set
|
|
97
|
+
result
|
|
98
|
+
}
|
|
85
99
|
end
|
|
86
100
|
|
|
87
101
|
# Public
|
|
@@ -107,6 +121,10 @@ module Flipper
|
|
|
107
121
|
|
|
108
122
|
private
|
|
109
123
|
|
|
124
|
+
def read_all_features(**kwargs)
|
|
125
|
+
@adapter.get_all(**kwargs)
|
|
126
|
+
end
|
|
127
|
+
|
|
110
128
|
# Private: Returns the Set of known feature keys.
|
|
111
129
|
def read_feature_keys
|
|
112
130
|
cache_fetch(@features_cache_key) { @adapter.features }
|
|
@@ -15,6 +15,10 @@ module Flipper
|
|
|
15
15
|
@remote = remote
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
+
def adapter_stack
|
|
19
|
+
"#{name}(local: #{@local.adapter_stack}, remote: #{@remote.adapter_stack})"
|
|
20
|
+
end
|
|
21
|
+
|
|
18
22
|
def features
|
|
19
23
|
@local.features
|
|
20
24
|
end
|
|
@@ -27,8 +31,8 @@ module Flipper
|
|
|
27
31
|
@local.get_multi(features)
|
|
28
32
|
end
|
|
29
33
|
|
|
30
|
-
def get_all
|
|
31
|
-
@local.get_all
|
|
34
|
+
def get_all(**kwargs)
|
|
35
|
+
@local.get_all(**kwargs)
|
|
32
36
|
end
|
|
33
37
|
|
|
34
38
|
def add(feature)
|
|
@@ -13,6 +13,8 @@ module Flipper
|
|
|
13
13
|
# primary is updated
|
|
14
14
|
# :errors - Array of exception types for which to failover
|
|
15
15
|
|
|
16
|
+
attr_reader :primary, :secondary
|
|
17
|
+
|
|
16
18
|
def initialize(primary, secondary, options = {})
|
|
17
19
|
@primary = primary
|
|
18
20
|
@secondary = secondary
|
|
@@ -21,6 +23,10 @@ module Flipper
|
|
|
21
23
|
@errors = options.fetch(:errors, [ StandardError ])
|
|
22
24
|
end
|
|
23
25
|
|
|
26
|
+
def adapter_stack
|
|
27
|
+
"#{name}(primary: #{@primary.adapter_stack}, secondary: #{@secondary.adapter_stack})"
|
|
28
|
+
end
|
|
29
|
+
|
|
24
30
|
def features
|
|
25
31
|
@primary.features
|
|
26
32
|
rescue *@errors
|
|
@@ -39,10 +45,10 @@ module Flipper
|
|
|
39
45
|
@secondary.get_multi(features)
|
|
40
46
|
end
|
|
41
47
|
|
|
42
|
-
def get_all
|
|
43
|
-
@primary.get_all
|
|
48
|
+
def get_all(**kwargs)
|
|
49
|
+
@primary.get_all(**kwargs)
|
|
44
50
|
rescue *@errors
|
|
45
|
-
@secondary.get_all
|
|
51
|
+
@secondary.get_all(**kwargs)
|
|
46
52
|
end
|
|
47
53
|
|
|
48
54
|
def add(feature)
|
|
@@ -45,12 +45,19 @@ module Flipper
|
|
|
45
45
|
end
|
|
46
46
|
|
|
47
47
|
def add_header(key, value)
|
|
48
|
-
key =
|
|
49
|
-
@headers[key] = value
|
|
48
|
+
@headers[normalize_header_key(key)] = value
|
|
50
49
|
end
|
|
51
50
|
|
|
52
|
-
def get(path)
|
|
53
|
-
|
|
51
|
+
def get(path, options = {})
|
|
52
|
+
headers = @headers.dup
|
|
53
|
+
|
|
54
|
+
if options[:headers]
|
|
55
|
+
options[:headers].each do |key, value|
|
|
56
|
+
headers[normalize_header_key(key)] = value
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
perform Net::HTTP::Get, path, headers
|
|
54
61
|
end
|
|
55
62
|
|
|
56
63
|
def post(path, body = nil)
|
|
@@ -125,6 +132,10 @@ module Flipper
|
|
|
125
132
|
def client_frameworks
|
|
126
133
|
CLIENT_FRAMEWORKS.transform_values { |detect| detect.call rescue nil }.compact
|
|
127
134
|
end
|
|
135
|
+
|
|
136
|
+
def normalize_header_key(key)
|
|
137
|
+
key.to_s.downcase.gsub('_'.freeze, '-'.freeze)
|
|
138
|
+
end
|
|
128
139
|
end
|
|
129
140
|
end
|
|
130
141
|
end
|
|
@@ -22,6 +22,10 @@ module Flipper
|
|
|
22
22
|
write_timeout: options[:write_timeout],
|
|
23
23
|
max_retries: options[:max_retries],
|
|
24
24
|
debug_output: options[:debug_output])
|
|
25
|
+
@last_get_all_etag = nil
|
|
26
|
+
@last_get_all_result = nil
|
|
27
|
+
@last_get_all_response = nil
|
|
28
|
+
@get_all_mutex = Mutex.new
|
|
25
29
|
end
|
|
26
30
|
|
|
27
31
|
def get(feature)
|
|
@@ -55,12 +59,33 @@ module Flipper
|
|
|
55
59
|
result
|
|
56
60
|
end
|
|
57
61
|
|
|
58
|
-
def get_all
|
|
59
|
-
|
|
62
|
+
def get_all(cache_bust: false)
|
|
63
|
+
options = {}
|
|
64
|
+
path = "/features?exclude_gate_names=true"
|
|
65
|
+
path += "&_cb=#{Time.now.to_i}" if cache_bust
|
|
66
|
+
etag = @get_all_mutex.synchronize { @last_get_all_etag }
|
|
67
|
+
|
|
68
|
+
if etag && !cache_bust
|
|
69
|
+
options[:headers] = { if_none_match: etag }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
response = @client.get(path, options)
|
|
73
|
+
@get_all_mutex.synchronize { @last_get_all_response = response }
|
|
74
|
+
|
|
75
|
+
if response.is_a?(Net::HTTPNotModified)
|
|
76
|
+
cached_result = @get_all_mutex.synchronize { @last_get_all_result }
|
|
77
|
+
|
|
78
|
+
if cached_result
|
|
79
|
+
return cached_result
|
|
80
|
+
else
|
|
81
|
+
raise Error, response
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
60
85
|
raise Error, response unless response.is_a?(Net::HTTPOK)
|
|
61
86
|
|
|
62
|
-
parsed_response = Typecast.from_json(response.body)
|
|
63
|
-
parsed_features = parsed_response
|
|
87
|
+
parsed_response = response.body.empty? ? {} : Typecast.from_json(response.body)
|
|
88
|
+
parsed_features = parsed_response['features'] || []
|
|
64
89
|
gates_by_key = parsed_features.each_with_object({}) do |parsed_feature, hash|
|
|
65
90
|
hash[parsed_feature['key']] = parsed_feature['gates']
|
|
66
91
|
hash
|
|
@@ -71,9 +96,19 @@ module Flipper
|
|
|
71
96
|
feature = Feature.new(key, self)
|
|
72
97
|
result[feature.key] = result_for_feature(feature, gates_by_key[feature.key])
|
|
73
98
|
end
|
|
99
|
+
|
|
100
|
+
@get_all_mutex.synchronize do
|
|
101
|
+
@last_get_all_etag = response['etag'] if response['etag']
|
|
102
|
+
@last_get_all_result = result
|
|
103
|
+
end
|
|
104
|
+
|
|
74
105
|
result
|
|
75
106
|
end
|
|
76
107
|
|
|
108
|
+
def last_get_all_response
|
|
109
|
+
@get_all_mutex.synchronize { @last_get_all_response }
|
|
110
|
+
end
|
|
111
|
+
|
|
77
112
|
def features
|
|
78
113
|
response = @client.get('/features?exclude_gate_names=true')
|
|
79
114
|
raise Error, response unless response.is_a?(Net::HTTPOK)
|
|
@@ -101,14 +101,14 @@ module Flipper
|
|
|
101
101
|
end
|
|
102
102
|
end
|
|
103
103
|
|
|
104
|
-
def get_all
|
|
104
|
+
def get_all(**kwargs)
|
|
105
105
|
default_payload = {
|
|
106
106
|
operation: :get_all,
|
|
107
107
|
adapter_name: @adapter.name,
|
|
108
108
|
}
|
|
109
109
|
|
|
110
110
|
@instrumenter.instrument(InstrumentationName, default_payload) do |payload|
|
|
111
|
-
payload[:result] = @adapter.get_all
|
|
111
|
+
payload[:result] = @adapter.get_all(**kwargs)
|
|
112
112
|
end
|
|
113
113
|
end
|
|
114
114
|
|
|
@@ -81,7 +81,7 @@ module Flipper
|
|
|
81
81
|
end
|
|
82
82
|
end
|
|
83
83
|
|
|
84
|
-
def get_all
|
|
84
|
+
def get_all(**kwargs)
|
|
85
85
|
if memoizing?
|
|
86
86
|
response = nil
|
|
87
87
|
if cache[@get_all_key]
|
|
@@ -90,7 +90,7 @@ module Flipper
|
|
|
90
90
|
response[key] = cache[key_for(key)]
|
|
91
91
|
end
|
|
92
92
|
else
|
|
93
|
-
response = @adapter.get_all
|
|
93
|
+
response = @adapter.get_all(**kwargs)
|
|
94
94
|
response.each do |key, value|
|
|
95
95
|
cache[key_for(key)] = value
|
|
96
96
|
end
|
|
@@ -103,7 +103,7 @@ module Flipper
|
|
|
103
103
|
response.default_proc = ->(memo, key) { memo[key] = default_config }
|
|
104
104
|
response
|
|
105
105
|
else
|
|
106
|
-
@adapter.get_all
|
|
106
|
+
@adapter.get_all(**kwargs)
|
|
107
107
|
end
|
|
108
108
|
end
|
|
109
109
|
|
|
@@ -18,6 +18,21 @@ module Flipper
|
|
|
18
18
|
@adapter = adapter
|
|
19
19
|
@poller = poller
|
|
20
20
|
@last_synced_at = 0
|
|
21
|
+
|
|
22
|
+
# If the adapter is empty, we need to sync before starting the poller.
|
|
23
|
+
# Yes, this will block the main thread, but that's better than thinking
|
|
24
|
+
# nothing is enabled.
|
|
25
|
+
if adapter.features.empty?
|
|
26
|
+
begin
|
|
27
|
+
@poller.sync
|
|
28
|
+
rescue
|
|
29
|
+
# TODO: Warn here that it's possible that no data has been synced
|
|
30
|
+
# and flags are being evaluated without flag data being present
|
|
31
|
+
# until a sync completes. We rescue to avoid flipper being down
|
|
32
|
+
# causing your processes to crash.
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
21
36
|
@poller.start
|
|
22
37
|
end
|
|
23
38
|
|
|
@@ -10,11 +10,40 @@ module Flipper
|
|
|
10
10
|
end
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
+
class << self
|
|
14
|
+
# Returns whether sync mode is enabled for the current thread.
|
|
15
|
+
# When sync mode is enabled, strict checks are not enforced,
|
|
16
|
+
# allowing sync operations to add features and bring local state
|
|
17
|
+
# in line with remote state.
|
|
18
|
+
def sync_mode
|
|
19
|
+
Thread.current[:flipper_strict_sync_mode]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def sync_mode=(value)
|
|
23
|
+
Thread.current[:flipper_strict_sync_mode] = value
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Executes a block with sync mode enabled. Strict checks will
|
|
27
|
+
# not be enforced within the block.
|
|
28
|
+
def with_sync_mode
|
|
29
|
+
old_value = sync_mode
|
|
30
|
+
self.sync_mode = true
|
|
31
|
+
yield
|
|
32
|
+
ensure
|
|
33
|
+
self.sync_mode = old_value
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
13
37
|
def initialize(adapter, handler = nil, &block)
|
|
14
38
|
super(adapter)
|
|
15
39
|
@handler = block || handler
|
|
16
40
|
end
|
|
17
41
|
|
|
42
|
+
def add(feature)
|
|
43
|
+
assert_feature_exists(feature) unless self.class.sync_mode
|
|
44
|
+
super
|
|
45
|
+
end
|
|
46
|
+
|
|
18
47
|
def get(feature)
|
|
19
48
|
assert_feature_exists(feature)
|
|
20
49
|
super
|
|
@@ -28,6 +57,7 @@ module Flipper
|
|
|
28
57
|
private
|
|
29
58
|
|
|
30
59
|
def assert_feature_exists(feature)
|
|
60
|
+
return if self.class.sync_mode
|
|
31
61
|
return if @adapter.features.include?(feature.key)
|
|
32
62
|
|
|
33
63
|
case handler
|
|
@@ -55,7 +55,11 @@ module Flipper
|
|
|
55
55
|
def sync_expression
|
|
56
56
|
return if local_expression == remote_expression
|
|
57
57
|
|
|
58
|
-
|
|
58
|
+
if remote_expression.nil?
|
|
59
|
+
@feature.disable_expression
|
|
60
|
+
else
|
|
61
|
+
@feature.enable_expression remote_expression
|
|
62
|
+
end
|
|
59
63
|
end
|
|
60
64
|
|
|
61
65
|
def sync_actors
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
require "flipper/feature"
|
|
2
2
|
require "flipper/gate_values"
|
|
3
|
+
require "flipper/adapters/actor_limit"
|
|
4
|
+
require "flipper/adapters/strict"
|
|
3
5
|
require "flipper/adapters/sync/feature_synchronizer"
|
|
4
6
|
|
|
5
7
|
module Flipper
|
|
@@ -16,27 +18,33 @@ module Flipper
|
|
|
16
18
|
# options - The Hash of options.
|
|
17
19
|
# :instrumenter - The instrumenter used to instrument.
|
|
18
20
|
# :raise - Should errors be raised (default: true).
|
|
21
|
+
# :cache_bust - Should cache busting be used for remote get_all (default: false).
|
|
19
22
|
def initialize(local, remote, options = {})
|
|
20
23
|
@local = local
|
|
21
24
|
@remote = remote
|
|
22
25
|
@instrumenter = options.fetch(:instrumenter, Instrumenters::Noop)
|
|
23
26
|
@raise = options.fetch(:raise, true)
|
|
27
|
+
@cache_bust = options.fetch(:cache_bust, false)
|
|
24
28
|
end
|
|
25
29
|
|
|
26
30
|
# Public: Forces a sync.
|
|
27
31
|
def call
|
|
28
|
-
@instrumenter.instrument("synchronizer_call.flipper")
|
|
32
|
+
@instrumenter.instrument("synchronizer_call.flipper") do
|
|
33
|
+
Flipper::Adapters::Strict.with_sync_mode do
|
|
34
|
+
Flipper::Adapters::ActorLimit.with_sync_mode { sync }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
29
37
|
end
|
|
30
38
|
|
|
31
39
|
private
|
|
32
40
|
|
|
33
41
|
def sync
|
|
34
42
|
local_get_all = @local.get_all
|
|
35
|
-
remote_get_all = @remote.get_all
|
|
43
|
+
remote_get_all = @remote.get_all(cache_bust: @cache_bust)
|
|
36
44
|
|
|
37
45
|
# Sync all the gate values.
|
|
38
46
|
remote_get_all.each do |feature_key, remote_gates_hash|
|
|
39
|
-
feature = Feature.new(feature_key, @local)
|
|
47
|
+
feature = Feature.new(feature_key, @local, instrumenter: @instrumenter)
|
|
40
48
|
# Check if feature_key is in hash before accessing to prevent unintended hash modification
|
|
41
49
|
local_gates_hash = local_get_all.key?(feature_key) ? local_get_all[feature_key] : @local.default_config
|
|
42
50
|
local_gate_values = GateValues.new(local_gates_hash)
|
|
@@ -46,11 +54,11 @@ module Flipper
|
|
|
46
54
|
|
|
47
55
|
# Add features that are missing in local and present in remote.
|
|
48
56
|
features_to_add = remote_get_all.keys - local_get_all.keys
|
|
49
|
-
features_to_add.each { |key| Feature.new(key, @local).add }
|
|
57
|
+
features_to_add.each { |key| Feature.new(key, @local, instrumenter: @instrumenter).add }
|
|
50
58
|
|
|
51
59
|
# Remove features that are present in local and missing in remote.
|
|
52
60
|
features_to_remove = local_get_all.keys - remote_get_all.keys
|
|
53
|
-
features_to_remove.each { |key| Feature.new(key, @local).remove }
|
|
61
|
+
features_to_remove.each { |key| Feature.new(key, @local, instrumenter: @instrumenter).remove }
|
|
54
62
|
|
|
55
63
|
nil
|
|
56
64
|
rescue => exception
|
|
@@ -9,7 +9,7 @@ module Flipper
|
|
|
9
9
|
include ::Flipper::Adapter
|
|
10
10
|
|
|
11
11
|
# Public: The synchronizer that will keep the local and remote in sync.
|
|
12
|
-
attr_reader :synchronizer
|
|
12
|
+
attr_reader :synchronizer, :local, :remote
|
|
13
13
|
|
|
14
14
|
# Public: Build a new sync instance.
|
|
15
15
|
#
|
|
@@ -33,6 +33,10 @@ module Flipper
|
|
|
33
33
|
synchronize
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
+
def adapter_stack
|
|
37
|
+
"#{name}(local: #{@local.adapter_stack}, remote: #{@remote.adapter_stack})"
|
|
38
|
+
end
|
|
39
|
+
|
|
36
40
|
def features
|
|
37
41
|
synchronize
|
|
38
42
|
@local.features
|
|
@@ -48,9 +52,9 @@ module Flipper
|
|
|
48
52
|
@local.get_multi(features)
|
|
49
53
|
end
|
|
50
54
|
|
|
51
|
-
def get_all
|
|
55
|
+
def get_all(**kwargs)
|
|
52
56
|
synchronize
|
|
53
|
-
@local.get_all
|
|
57
|
+
@local.get_all(**kwargs)
|
|
54
58
|
end
|
|
55
59
|
|
|
56
60
|
def add(feature)
|
data/lib/flipper/cli.rb
CHANGED
|
@@ -84,6 +84,57 @@ module Flipper
|
|
|
84
84
|
end
|
|
85
85
|
end
|
|
86
86
|
|
|
87
|
+
command 'export' do |c|
|
|
88
|
+
c.description = "Export features as JSON"
|
|
89
|
+
c.action do
|
|
90
|
+
export = Flipper.export(format: :json, version: 1)
|
|
91
|
+
ui.info export.contents
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
command 'cloud' do |c|
|
|
96
|
+
c.description = "Flipper Cloud commands"
|
|
97
|
+
c.action do |subcommand = nil, *args|
|
|
98
|
+
require 'flipper/cloud/migrate'
|
|
99
|
+
|
|
100
|
+
case subcommand
|
|
101
|
+
when 'migrate'
|
|
102
|
+
result = Flipper::Cloud.migrate(Flipper)
|
|
103
|
+
if result.url
|
|
104
|
+
ui.info "Migrating to Flipper Cloud..."
|
|
105
|
+
ui.info result.url
|
|
106
|
+
system("open", result.url)
|
|
107
|
+
else
|
|
108
|
+
message = "Migration failed (HTTP #{result.code})"
|
|
109
|
+
message << ": #{result.message}" if result.message
|
|
110
|
+
ui.error message
|
|
111
|
+
exit 1
|
|
112
|
+
end
|
|
113
|
+
when 'push'
|
|
114
|
+
token = args.first
|
|
115
|
+
unless token
|
|
116
|
+
ui.error "Usage: flipper cloud push <token>"
|
|
117
|
+
exit 1
|
|
118
|
+
end
|
|
119
|
+
result = Flipper::Cloud.push(token, Flipper)
|
|
120
|
+
if result.code == 204
|
|
121
|
+
ui.info "Successfully pushed features to Flipper Cloud"
|
|
122
|
+
else
|
|
123
|
+
message = "Push failed (HTTP #{result.code})"
|
|
124
|
+
message << ": #{result.message}" if result.message
|
|
125
|
+
ui.error message
|
|
126
|
+
exit 1
|
|
127
|
+
end
|
|
128
|
+
else
|
|
129
|
+
ui.info "Usage: flipper cloud <command>"
|
|
130
|
+
ui.info ""
|
|
131
|
+
ui.info "Commands:"
|
|
132
|
+
ui.info " migrate Migrate features to a new Flipper Cloud account"
|
|
133
|
+
ui.info " push Push features to an existing Flipper Cloud project"
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
87
138
|
command 'help' do |c|
|
|
88
139
|
c.load_environment = false
|
|
89
140
|
c.action do |command = nil|
|
|
@@ -3,7 +3,6 @@ require "socket"
|
|
|
3
3
|
require "flipper/adapters/http"
|
|
4
4
|
require "flipper/adapters/poll"
|
|
5
5
|
require "flipper/poller"
|
|
6
|
-
require "flipper/adapters/memory"
|
|
7
6
|
require "flipper/adapters/dual_write"
|
|
8
7
|
require "flipper/adapters/sync/synchronizer"
|
|
9
8
|
require "flipper/cloud/telemetry"
|
|
@@ -111,9 +110,10 @@ module Flipper
|
|
|
111
110
|
end
|
|
112
111
|
|
|
113
112
|
# Public: Force a sync.
|
|
114
|
-
def sync
|
|
113
|
+
def sync(cache_bust: false)
|
|
115
114
|
Flipper::Adapters::Sync::Synchronizer.new(local_adapter, http_adapter, {
|
|
116
115
|
instrumenter: instrumenter,
|
|
116
|
+
cache_bust: cache_bust,
|
|
117
117
|
}).call
|
|
118
118
|
end
|
|
119
119
|
|
|
@@ -135,11 +135,14 @@ module Flipper
|
|
|
135
135
|
logger.send(level, "name=flipper_cloud #{message}")
|
|
136
136
|
end
|
|
137
137
|
|
|
138
|
+
def instrument(name, payload = {}, &block)
|
|
139
|
+
instrumenter.instrument(name, payload, &block)
|
|
140
|
+
end
|
|
141
|
+
|
|
138
142
|
private
|
|
139
143
|
|
|
140
144
|
def app_adapter
|
|
141
|
-
|
|
142
|
-
Flipper::Adapters::DualWrite.new(read_adapter, http_adapter)
|
|
145
|
+
Flipper::Adapters::DualWrite.new(poll_adapter, http_adapter)
|
|
143
146
|
end
|
|
144
147
|
|
|
145
148
|
def poller
|
|
@@ -198,8 +201,13 @@ module Flipper
|
|
|
198
201
|
end
|
|
199
202
|
|
|
200
203
|
def setup_sync(options)
|
|
201
|
-
set_option :sync_interval, options, default: 10, typecast: :float, minimum: 10
|
|
202
204
|
set_option :sync_secret, options
|
|
205
|
+
|
|
206
|
+
# 1 hour for webhook, 10 seconds for poll. If using webhooks we don't
|
|
207
|
+
# need to sync as often but we should still sync occasionally to avoid
|
|
208
|
+
# any chance of stale data.
|
|
209
|
+
default_interval = sync_method == :webhook ? 3600 : 10
|
|
210
|
+
set_option :sync_interval, options, default: default_interval, typecast: :float, minimum: 10
|
|
203
211
|
end
|
|
204
212
|
|
|
205
213
|
def setup_adapter(options)
|
|
@@ -242,7 +250,7 @@ module Flipper
|
|
|
242
250
|
if required
|
|
243
251
|
option_value = send(name)
|
|
244
252
|
if option_value.nil? || option_value.empty?
|
|
245
|
-
message = "Flipper::Cloud #{name} is missing. Please "
|
|
253
|
+
message = String.new("Flipper::Cloud #{name} is missing. Please ")
|
|
246
254
|
message << "set #{env_var} or " if from_env
|
|
247
255
|
message << "provide #{name} (e.g. Flipper::Cloud.new(#{name}: value))."
|
|
248
256
|
raise ArgumentError, message
|