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/cloud/dsl.rb
CHANGED
|
@@ -35,7 +35,7 @@ module Flipper
|
|
|
35
35
|
message_verifier = MessageVerifier.new(secret: flipper.sync_secret)
|
|
36
36
|
if message_verifier.verify(payload, signature)
|
|
37
37
|
begin
|
|
38
|
-
flipper.sync
|
|
38
|
+
flipper.sync(cache_bust: true)
|
|
39
39
|
body = JSON.generate({
|
|
40
40
|
groups: Flipper.group_names.map { |name| {name: name}}
|
|
41
41
|
})
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
require "flipper/adapters/http/client"
|
|
2
|
+
require "flipper/typecast"
|
|
3
|
+
|
|
4
|
+
module Flipper
|
|
5
|
+
module Cloud
|
|
6
|
+
MigrateResult = Struct.new(:code, :url, :message, keyword_init: true)
|
|
7
|
+
|
|
8
|
+
DEFAULT_CLOUD_URL = "https://www.flippercloud.io".freeze
|
|
9
|
+
|
|
10
|
+
# Public: Migrate features to Flipper Cloud.
|
|
11
|
+
#
|
|
12
|
+
# flipper - The Flipper instance to export features from (default: Flipper).
|
|
13
|
+
# app_name - Optional String name of the application.
|
|
14
|
+
#
|
|
15
|
+
# Returns a MigrateResult with code, url, and message.
|
|
16
|
+
def self.migrate(flipper = Flipper, app_name: nil)
|
|
17
|
+
export = flipper.export(format: :json, version: 1)
|
|
18
|
+
payload = {
|
|
19
|
+
export: Typecast.from_json(export.contents),
|
|
20
|
+
metadata: {app_name: app_name},
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
client = build_client("/api")
|
|
24
|
+
response = client.post("/migrate", Typecast.to_gzip(Typecast.to_json(payload)))
|
|
25
|
+
body = Typecast.from_json(response.body) rescue nil
|
|
26
|
+
|
|
27
|
+
MigrateResult.new(
|
|
28
|
+
code: response.code.to_i,
|
|
29
|
+
url: body&.dig("url"),
|
|
30
|
+
message: body&.dig("error"),
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Public: Push features to an existing Flipper Cloud project.
|
|
35
|
+
#
|
|
36
|
+
# token - The String token for the Cloud environment.
|
|
37
|
+
# flipper - The Flipper instance to export features from (default: Flipper).
|
|
38
|
+
#
|
|
39
|
+
# Returns a MigrateResult with code and message.
|
|
40
|
+
def self.push(token, flipper = Flipper)
|
|
41
|
+
export = flipper.export(format: :json, version: 1)
|
|
42
|
+
|
|
43
|
+
client = build_client("/adapter", headers: {
|
|
44
|
+
"flipper-cloud-token" => token,
|
|
45
|
+
})
|
|
46
|
+
response = client.post("/import", Typecast.to_gzip(export.contents))
|
|
47
|
+
body = Typecast.from_json(response.body) rescue nil
|
|
48
|
+
|
|
49
|
+
MigrateResult.new(
|
|
50
|
+
code: response.code.to_i,
|
|
51
|
+
url: nil,
|
|
52
|
+
message: body&.dig("error"),
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Private: Build an HTTP client for Cloud API requests.
|
|
57
|
+
def self.build_client(path, headers: {})
|
|
58
|
+
base_url = ENV.fetch("FLIPPER_CLOUD_URL", DEFAULT_CLOUD_URL)
|
|
59
|
+
|
|
60
|
+
Flipper::Adapters::Http::Client.new(
|
|
61
|
+
url: "#{base_url}#{path}",
|
|
62
|
+
headers: {"content-encoding" => "gzip"}.merge(headers),
|
|
63
|
+
open_timeout: 5,
|
|
64
|
+
read_timeout: 30,
|
|
65
|
+
write_timeout: 30,
|
|
66
|
+
max_retries: 2,
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
private_class_method :build_client
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -3,10 +3,10 @@ module Flipper
|
|
|
3
3
|
class Telemetry
|
|
4
4
|
class BackoffPolicy
|
|
5
5
|
# Private: The default minimum timeout between intervals in milliseconds.
|
|
6
|
-
MIN_TIMEOUT_MS =
|
|
6
|
+
MIN_TIMEOUT_MS = 30_000
|
|
7
7
|
|
|
8
8
|
# Private: The default maximum timeout between intervals in milliseconds.
|
|
9
|
-
MAX_TIMEOUT_MS =
|
|
9
|
+
MAX_TIMEOUT_MS = 120_000
|
|
10
10
|
|
|
11
11
|
# Private: The value to multiply the current interval with for each
|
|
12
12
|
# retry attempt.
|
|
@@ -67,7 +67,10 @@ module Flipper
|
|
|
67
67
|
|
|
68
68
|
@attempts += 1
|
|
69
69
|
|
|
70
|
-
|
|
70
|
+
# cap the interval to the max timeout
|
|
71
|
+
result = [interval, @max_timeout_ms].min
|
|
72
|
+
# jitter even when maxed out
|
|
73
|
+
result == @max_timeout_ms ? add_jitter(result, 0.05) : result
|
|
71
74
|
end
|
|
72
75
|
|
|
73
76
|
def reset
|
|
@@ -34,7 +34,7 @@ module Flipper
|
|
|
34
34
|
return if drained.empty?
|
|
35
35
|
body = to_body(drained)
|
|
36
36
|
return if body.nil? || body.empty?
|
|
37
|
-
retry_with_backoff(
|
|
37
|
+
retry_with_backoff(5) { submit(body) }
|
|
38
38
|
end
|
|
39
39
|
|
|
40
40
|
private
|
|
@@ -51,6 +51,7 @@ module Flipper
|
|
|
51
51
|
|
|
52
52
|
Typecast.to_gzip(json)
|
|
53
53
|
rescue => exception
|
|
54
|
+
@cloud_configuration.instrument "telemetry_error.#{Flipper::InstrumentationNamespace}", exception: exception, request_id: request_id
|
|
54
55
|
@cloud_configuration.log "action=to_body request_id=#{request_id} error=#{exception.inspect}", level: :error
|
|
55
56
|
end
|
|
56
57
|
|
|
@@ -63,6 +64,7 @@ module Flipper
|
|
|
63
64
|
result, should_retry = yield
|
|
64
65
|
return [result, nil] unless should_retry
|
|
65
66
|
rescue => error
|
|
67
|
+
@cloud_configuration.instrument "telemetry_retry.#{Flipper::InstrumentationNamespace}", attempts_remaining: attempts_remaining, exception: error
|
|
66
68
|
@cloud_configuration.log "action=post_to_cloud attempts_remaining=#{attempts_remaining} error=#{error.inspect}", level: :error
|
|
67
69
|
should_retry = true
|
|
68
70
|
caught_exception = error
|
|
@@ -75,8 +75,8 @@ module Flipper
|
|
|
75
75
|
|
|
76
76
|
@metric_storage = MetricStorage.new
|
|
77
77
|
|
|
78
|
-
@pool = Concurrent::FixedThreadPool.new(
|
|
79
|
-
max_queue:
|
|
78
|
+
@pool = Concurrent::FixedThreadPool.new(1, {
|
|
79
|
+
max_queue: 20, # ~ 20 minutes of data at 1 minute intervals
|
|
80
80
|
fallback_policy: :discard,
|
|
81
81
|
name: "flipper-telemetry-post-to-cloud-pool".freeze,
|
|
82
82
|
})
|
|
@@ -168,7 +168,7 @@ module Flipper
|
|
|
168
168
|
end
|
|
169
169
|
|
|
170
170
|
if interval = response["telemetry-interval"]
|
|
171
|
-
self.interval = interval
|
|
171
|
+
self.interval = interval
|
|
172
172
|
end
|
|
173
173
|
end
|
|
174
174
|
rescue => error
|
data/lib/flipper/cloud.rb
CHANGED
data/lib/flipper/dsl.rb
CHANGED
|
@@ -10,7 +10,7 @@ module Flipper
|
|
|
10
10
|
# Private: What is being used to instrument all the things.
|
|
11
11
|
attr_reader :instrumenter
|
|
12
12
|
|
|
13
|
-
def_delegators :@adapter, :memoize=, :memoizing?, :import, :export
|
|
13
|
+
def_delegators :@adapter, :memoize=, :memoizing?, :import, :export, :adapter_stack
|
|
14
14
|
|
|
15
15
|
# Public: Returns a new instance of the DSL.
|
|
16
16
|
#
|
data/lib/flipper/export.rb
CHANGED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
require "set"
|
|
2
|
+
|
|
3
|
+
module Flipper
|
|
4
|
+
module Expressions
|
|
5
|
+
class FeatureEnabled
|
|
6
|
+
EVALUATING_KEY = :flipper_evaluating_features
|
|
7
|
+
|
|
8
|
+
def self.call(feature_name, context:)
|
|
9
|
+
evaluating = Thread.current[EVALUATING_KEY] ||= Set.new
|
|
10
|
+
feature_name = feature_name.to_s
|
|
11
|
+
current_feature = context[:feature_name].to_s
|
|
12
|
+
|
|
13
|
+
# Track the current feature so A -> B -> A is caught
|
|
14
|
+
added_current = evaluating.add?(current_feature)
|
|
15
|
+
|
|
16
|
+
begin
|
|
17
|
+
# Circular dependency: return false to break the cycle
|
|
18
|
+
return false if evaluating.include?(feature_name)
|
|
19
|
+
|
|
20
|
+
evaluating.add(feature_name)
|
|
21
|
+
actor = context[:actor]
|
|
22
|
+
if actor
|
|
23
|
+
Flipper.enabled?(feature_name, actor)
|
|
24
|
+
else
|
|
25
|
+
Flipper.enabled?(feature_name)
|
|
26
|
+
end
|
|
27
|
+
ensure
|
|
28
|
+
evaluating.delete(feature_name)
|
|
29
|
+
evaluating.delete(current_feature) if added_current
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
data/lib/flipper/feature.rb
CHANGED
|
@@ -100,7 +100,14 @@ module Flipper
|
|
|
100
100
|
#
|
|
101
101
|
# Returns true if enabled, false if not.
|
|
102
102
|
def enabled?(*actors)
|
|
103
|
-
actors = actors.
|
|
103
|
+
actors = Array(actors).
|
|
104
|
+
# Avoids to_ary warning that happens when passing DelegateClass of an
|
|
105
|
+
# ActiveRecord object and using flatten here. This is tested in
|
|
106
|
+
# spec/flipper/model/active_record_spec.rb.
|
|
107
|
+
flat_map { |actor| actor.is_a?(Array) ? actor : [actor] }.
|
|
108
|
+
# Allows null object pattern. See PR for more. https://github.com/flippercloud/flipper/pull/887
|
|
109
|
+
reject(&:nil?).
|
|
110
|
+
map { |actor| Types::Actor.wrap(actor) }
|
|
104
111
|
actors = nil if actors.empty?
|
|
105
112
|
|
|
106
113
|
# thing is left for backwards compatibility
|
data/lib/flipper/gate.rb
CHANGED
|
@@ -30,10 +30,10 @@ module Flipper
|
|
|
30
30
|
expression = Flipper::Expression.build(data)
|
|
31
31
|
|
|
32
32
|
if context.actors.nil? || context.actors.empty?
|
|
33
|
-
!!expression.evaluate(feature_name: context.feature_name, properties: DEFAULT_PROPERTIES)
|
|
33
|
+
!!expression.evaluate(feature_name: context.feature_name, properties: DEFAULT_PROPERTIES, actor: nil)
|
|
34
34
|
else
|
|
35
35
|
context.actors.any? do |actor|
|
|
36
|
-
!!expression.evaluate(feature_name: context.feature_name, properties: properties(actor))
|
|
36
|
+
!!expression.evaluate(feature_name: context.feature_name, properties: properties(actor), actor: actor)
|
|
37
37
|
end
|
|
38
38
|
end
|
|
39
39
|
end
|
|
@@ -53,11 +53,10 @@ module Flipper
|
|
|
53
53
|
|
|
54
54
|
feature_name = event.payload[:feature_name]
|
|
55
55
|
adapter_name = event.payload[:adapter_name]
|
|
56
|
-
gate_name = event.payload[:gate_name]
|
|
57
56
|
operation = event.payload[:operation]
|
|
58
57
|
result = event.payload[:result]
|
|
59
58
|
|
|
60
|
-
description = 'Flipper '
|
|
59
|
+
description = String.new('Flipper ')
|
|
61
60
|
description << "feature(#{feature_name}) " unless feature_name.nil?
|
|
62
61
|
description << "adapter(#{adapter_name}) "
|
|
63
62
|
description << "#{operation} "
|
|
@@ -2,5 +2,7 @@ require 'securerandom'
|
|
|
2
2
|
require 'active_support/notifications'
|
|
3
3
|
require 'flipper/instrumentation/statsd_subscriber'
|
|
4
4
|
|
|
5
|
-
ActiveSupport::Notifications.subscribe
|
|
6
|
-
|
|
5
|
+
ActiveSupport::Notifications.subscribe(
|
|
6
|
+
/\.flipper$/,
|
|
7
|
+
Flipper::Instrumentation::StatsdSubscriber
|
|
8
|
+
)
|
|
@@ -42,7 +42,6 @@ module Flipper
|
|
|
42
42
|
# Private
|
|
43
43
|
def update_feature_operation_metrics
|
|
44
44
|
feature_name = @payload[:feature_name]
|
|
45
|
-
gate_name = @payload[:gate_name]
|
|
46
45
|
operation = strip_trailing_question_mark(@payload[:operation])
|
|
47
46
|
result = @payload[:result]
|
|
48
47
|
|
|
@@ -64,9 +63,6 @@ module Flipper
|
|
|
64
63
|
def update_adapter_operation_metrics
|
|
65
64
|
adapter_name = @payload[:adapter_name]
|
|
66
65
|
operation = @payload[:operation]
|
|
67
|
-
result = @payload[:result]
|
|
68
|
-
value = @payload[:value]
|
|
69
|
-
key = @payload[:key]
|
|
70
66
|
|
|
71
67
|
update_timer "flipper.adapter.#{adapter_name}.#{operation}"
|
|
72
68
|
end
|
data/lib/flipper/metadata.rb
CHANGED
|
@@ -7,5 +7,6 @@ module Flipper
|
|
|
7
7
|
"source_code_uri" => "https://github.com/flippercloud/flipper",
|
|
8
8
|
"bug_tracker_uri" => "https://github.com/flippercloud/flipper/issues",
|
|
9
9
|
"changelog_uri" => "https://github.com/flippercloud/flipper/releases/tag/v#{Flipper::VERSION}",
|
|
10
|
+
"funding_uri" => "https://github.com/sponsors/flippercloud",
|
|
10
11
|
}.freeze
|
|
11
12
|
end
|
data/lib/flipper/poller.rb
CHANGED
|
@@ -2,6 +2,7 @@ require 'logger'
|
|
|
2
2
|
require 'concurrent/utility/monotonic_time'
|
|
3
3
|
require 'concurrent/map'
|
|
4
4
|
require 'concurrent/atomic/atomic_fixnum'
|
|
5
|
+
require 'concurrent/atomic/atomic_boolean'
|
|
5
6
|
|
|
6
7
|
module Flipper
|
|
7
8
|
class Poller
|
|
@@ -17,7 +18,10 @@ module Flipper
|
|
|
17
18
|
end
|
|
18
19
|
|
|
19
20
|
def self.reset
|
|
20
|
-
instances.each
|
|
21
|
+
instances.each do |_, instance|
|
|
22
|
+
instance.stop
|
|
23
|
+
instance.thread&.join(1)
|
|
24
|
+
end.clear
|
|
21
25
|
end
|
|
22
26
|
|
|
23
27
|
MINIMUM_POLL_INTERVAL = 10
|
|
@@ -28,14 +32,12 @@ module Flipper
|
|
|
28
32
|
@mutex = Mutex.new
|
|
29
33
|
@instrumenter = options.fetch(:instrumenter, Instrumenters::Noop)
|
|
30
34
|
@remote_adapter = options.fetch(:remote_adapter)
|
|
31
|
-
@interval = options.fetch(:interval, 10).to_f
|
|
32
35
|
@last_synced_at = Concurrent::AtomicFixnum.new(0)
|
|
33
36
|
@adapter = Adapters::Memory.new(nil, threadsafe: true)
|
|
37
|
+
@shutdown_requested = Concurrent::AtomicBoolean.new(false)
|
|
34
38
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
@interval = MINIMUM_POLL_INTERVAL
|
|
38
|
-
end
|
|
39
|
+
self.interval = options.fetch(:interval, 10)
|
|
40
|
+
@initial_interval = @interval
|
|
39
41
|
|
|
40
42
|
@start_automatically = options.fetch(:start_automatically, true)
|
|
41
43
|
|
|
@@ -46,6 +48,7 @@ module Flipper
|
|
|
46
48
|
|
|
47
49
|
def start
|
|
48
50
|
reset if forked?
|
|
51
|
+
return if @shutdown_requested.true?
|
|
49
52
|
ensure_worker_running
|
|
50
53
|
end
|
|
51
54
|
|
|
@@ -59,10 +62,10 @@ module Flipper
|
|
|
59
62
|
def run
|
|
60
63
|
loop do
|
|
61
64
|
sleep jitter
|
|
62
|
-
|
|
65
|
+
|
|
63
66
|
begin
|
|
64
67
|
sync
|
|
65
|
-
rescue
|
|
68
|
+
rescue
|
|
66
69
|
# you can instrument these using poller.flipper
|
|
67
70
|
end
|
|
68
71
|
|
|
@@ -72,15 +75,33 @@ module Flipper
|
|
|
72
75
|
|
|
73
76
|
def sync
|
|
74
77
|
@instrumenter.instrument("poller.#{InstrumentationNamespace}", operation: :poll) do
|
|
75
|
-
|
|
76
|
-
|
|
78
|
+
begin
|
|
79
|
+
@adapter.import @remote_adapter
|
|
80
|
+
@last_synced_at.update { |time| Concurrent.monotonic_time }
|
|
81
|
+
ensure
|
|
82
|
+
apply_response_headers
|
|
83
|
+
end
|
|
77
84
|
end
|
|
78
85
|
end
|
|
79
86
|
|
|
87
|
+
# Internal: Sets the interval in seconds for how often to poll.
|
|
88
|
+
def interval=(value)
|
|
89
|
+
requested_interval = Flipper::Typecast.to_float(value)
|
|
90
|
+
new_interval = [requested_interval, MINIMUM_POLL_INTERVAL].max
|
|
91
|
+
|
|
92
|
+
if requested_interval < MINIMUM_POLL_INTERVAL
|
|
93
|
+
warn "Flipper::Cloud poll interval must be greater than or equal to #{MINIMUM_POLL_INTERVAL} but was #{requested_interval}. Setting interval to #{MINIMUM_POLL_INTERVAL}."
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
@interval = new_interval
|
|
97
|
+
end
|
|
98
|
+
|
|
80
99
|
private
|
|
81
100
|
|
|
82
101
|
def jitter
|
|
83
|
-
|
|
102
|
+
# Cap jitter at 30 seconds to prevent excessive delays for large intervals
|
|
103
|
+
max_jitter = [interval * 0.1, 30].min
|
|
104
|
+
rand * max_jitter
|
|
84
105
|
end
|
|
85
106
|
|
|
86
107
|
def forked?
|
|
@@ -98,6 +119,7 @@ module Flipper
|
|
|
98
119
|
begin
|
|
99
120
|
return if thread_alive?
|
|
100
121
|
@thread = Thread.new { run }
|
|
122
|
+
@thread&.report_on_exception = false
|
|
101
123
|
@instrumenter.instrument("poller.#{InstrumentationNamespace}", {
|
|
102
124
|
operation: :thread_start,
|
|
103
125
|
})
|
|
@@ -112,7 +134,28 @@ module Flipper
|
|
|
112
134
|
|
|
113
135
|
def reset
|
|
114
136
|
@pid = Process.pid
|
|
137
|
+
@shutdown_requested.make_false
|
|
115
138
|
mutex.unlock if mutex.locked?
|
|
116
139
|
end
|
|
140
|
+
|
|
141
|
+
def apply_response_headers
|
|
142
|
+
return unless @remote_adapter.respond_to?(:last_get_all_response)
|
|
143
|
+
|
|
144
|
+
if response = @remote_adapter.last_get_all_response
|
|
145
|
+
# shutdown based on response header
|
|
146
|
+
if Flipper::Typecast.to_boolean(response["poll-shutdown"])
|
|
147
|
+
@shutdown_requested.make_true
|
|
148
|
+
@instrumenter.instrument("poller.#{InstrumentationNamespace}", {
|
|
149
|
+
operation: :shutdown_requested,
|
|
150
|
+
})
|
|
151
|
+
stop
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# update interval based on response header
|
|
155
|
+
if interval = response["poll-interval"]
|
|
156
|
+
self.interval = [Flipper::Typecast.to_float(interval), @initial_interval].max
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
117
160
|
end
|
|
118
161
|
end
|
data/lib/flipper/version.rb
CHANGED
data/lib/flipper.rb
CHANGED
|
@@ -64,7 +64,7 @@ module Flipper
|
|
|
64
64
|
:enable_percentage_of_actors, :disable_percentage_of_actors,
|
|
65
65
|
:enable_percentage_of_time, :disable_percentage_of_time,
|
|
66
66
|
:features, :feature, :[], :preload, :preload_all,
|
|
67
|
-
:adapter, :add, :exist?, :remove, :import, :export,
|
|
67
|
+
:adapter, :adapter_stack, :add, :exist?, :remove, :import, :export,
|
|
68
68
|
:memoize=, :memoizing?, :read_only?,
|
|
69
69
|
:sync, :sync_secret # For Flipper::Cloud. Will error for OSS Flipper.
|
|
70
70
|
|
|
@@ -100,6 +100,22 @@ module Flipper
|
|
|
100
100
|
Expression.build({ Random: max })
|
|
101
101
|
end
|
|
102
102
|
|
|
103
|
+
def now
|
|
104
|
+
Expression.build({ Now: [] })
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def time(value)
|
|
108
|
+
Expression.build({ Time: value })
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def feature_enabled(name)
|
|
112
|
+
Expression.build({ FeatureEnabled: name })
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def feature_disabled(name)
|
|
116
|
+
feature_enabled(name).eq(false)
|
|
117
|
+
end
|
|
118
|
+
|
|
103
119
|
# Public: Use this to register a group by name.
|
|
104
120
|
#
|
|
105
121
|
# name - The Symbol name of the group.
|
|
@@ -4,10 +4,15 @@ module Flipper
|
|
|
4
4
|
module Generators
|
|
5
5
|
class SetupGenerator < ::Rails::Generators::Base
|
|
6
6
|
desc 'Peform any necessary steps to install Flipper'
|
|
7
|
+
source_paths << File.expand_path('templates', __dir__)
|
|
7
8
|
|
|
8
9
|
class_option :token, type: :string, default: nil, aliases: '-t',
|
|
9
10
|
desc: "Your personal environment token for Flipper Cloud"
|
|
10
11
|
|
|
12
|
+
def generate_initializer
|
|
13
|
+
template 'initializer.rb', 'config/initializers/flipper.rb'
|
|
14
|
+
end
|
|
15
|
+
|
|
11
16
|
def generate_active_record
|
|
12
17
|
invoke 'flipper:active_record' if defined?(Flipper::Adapters::ActiveRecord)
|
|
13
18
|
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
Rails.application.configure do
|
|
2
|
+
## Memoization ensures that only one adapter call is made per feature per request.
|
|
3
|
+
## For more info, see https://www.flippercloud.io/docs/optimization#memoization
|
|
4
|
+
# config.flipper.memoize = true
|
|
5
|
+
|
|
6
|
+
## Flipper preloads all features before each request, which is recommended if:
|
|
7
|
+
## * you have a limited number of features (< 100?)
|
|
8
|
+
## * most of your requests depend on most of your features
|
|
9
|
+
## * you have limited gate data combined across all features (< 1k enabled gates, like individual actors, across all features)
|
|
10
|
+
##
|
|
11
|
+
## For more info, see https://www.flippercloud.io/docs/optimization#preloading
|
|
12
|
+
# config.flipper.preload = true
|
|
13
|
+
|
|
14
|
+
## Warn or raise an error if an unknown feature is checked
|
|
15
|
+
## Can be set to `:warn`, `:raise`, or `false`
|
|
16
|
+
# config.flipper.strict = Rails.env.development? && :warn
|
|
17
|
+
|
|
18
|
+
## Show Flipper checks in logs
|
|
19
|
+
# config.flipper.log = true
|
|
20
|
+
|
|
21
|
+
## Reconfigure Flipper to use the Memory adapter and disable Cloud in tests
|
|
22
|
+
# config.flipper.test_help = true
|
|
23
|
+
|
|
24
|
+
## The path that Flipper Cloud will use to sync features
|
|
25
|
+
# config.flipper.cloud_path = "_flipper"
|
|
26
|
+
|
|
27
|
+
## The instrumenter that Flipper will use. Defaults to ActiveSupport::Notifications.
|
|
28
|
+
# config.flipper.instrumenter = ActiveSupport::Notifications
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
Flipper.configure do |config|
|
|
32
|
+
## Configure other adapters that you want to use here:
|
|
33
|
+
## See http://flippercloud.io/docs/adapters
|
|
34
|
+
# config.use Flipper::Adapters::ActiveSupportCacheStore, Rails.cache, expires_in: 5.minutes
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
## Register a group that can be used for enabling features.
|
|
38
|
+
##
|
|
39
|
+
## Flipper.enable_group :my_feature, :admins
|
|
40
|
+
##
|
|
41
|
+
## See https://www.flippercloud.io/docs/features#enablement-group
|
|
42
|
+
#
|
|
43
|
+
# Flipper.register(:admins) do |actor|
|
|
44
|
+
# actor.respond_to?(:admin?) && actor.admin?
|
|
45
|
+
# end
|
|
@@ -143,4 +143,24 @@ RSpec.describe Flipper::Adapter do
|
|
|
143
143
|
expect(export.features.dig("search", :boolean)).to eq("true")
|
|
144
144
|
end
|
|
145
145
|
end
|
|
146
|
+
|
|
147
|
+
describe "#adapter_stack" do
|
|
148
|
+
it "returns the adapter name for a simple adapter" do
|
|
149
|
+
adapter = Flipper::Adapters::Memory.new
|
|
150
|
+
expect(adapter.adapter_stack).to eq("memory")
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
it "returns the chain for wrapped adapters" do
|
|
154
|
+
memory = Flipper::Adapters::Memory.new
|
|
155
|
+
memoizable = Flipper::Adapters::Memoizable.new(memory)
|
|
156
|
+
expect(memoizable.adapter_stack).to eq("memoizable -> memory")
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
it "returns the chain for deeply nested adapters" do
|
|
160
|
+
memory = Flipper::Adapters::Memory.new
|
|
161
|
+
strict = Flipper::Adapters::Strict.new(memory)
|
|
162
|
+
memoizable = Flipper::Adapters::Memoizable.new(strict)
|
|
163
|
+
expect(memoizable.adapter_stack).to eq("memoizable -> strict -> memory")
|
|
164
|
+
end
|
|
165
|
+
end
|
|
146
166
|
end
|
|
@@ -15,6 +15,61 @@ RSpec.describe Flipper::Adapters::ActorLimit do
|
|
|
15
15
|
feature.enable Flipper::Actor.new("User;6")
|
|
16
16
|
}.to raise_error(Flipper::Adapters::ActorLimit::LimitExceeded)
|
|
17
17
|
end
|
|
18
|
+
|
|
19
|
+
it "allows exceeding limit when in sync mode" do
|
|
20
|
+
5.times { |i| feature.enable Flipper::Actor.new("User;#{i}") }
|
|
21
|
+
|
|
22
|
+
described_class.with_sync_mode do
|
|
23
|
+
expect {
|
|
24
|
+
feature.enable Flipper::Actor.new("User;6")
|
|
25
|
+
}.not_to raise_error
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
describe '.sync_mode' do
|
|
32
|
+
after do
|
|
33
|
+
described_class.sync_mode = nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it 'defaults to nil/falsy' do
|
|
37
|
+
expect(described_class.sync_mode).to be_falsy
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it 'can be set and read' do
|
|
41
|
+
described_class.sync_mode = true
|
|
42
|
+
expect(described_class.sync_mode).to be true
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
describe '.with_sync_mode' do
|
|
47
|
+
after do
|
|
48
|
+
described_class.sync_mode = nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
it 'sets sync_mode to true within block' do
|
|
52
|
+
described_class.with_sync_mode do
|
|
53
|
+
expect(described_class.sync_mode).to be true
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
it 'restores previous value after block' do
|
|
58
|
+
expect(described_class.sync_mode).to be_falsy
|
|
59
|
+
described_class.with_sync_mode { }
|
|
60
|
+
expect(described_class.sync_mode).to be_falsy
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it 'restores previous value even on exception' do
|
|
64
|
+
expect {
|
|
65
|
+
described_class.with_sync_mode { raise "boom" }
|
|
66
|
+
}.to raise_error("boom")
|
|
67
|
+
expect(described_class.sync_mode).to be_falsy
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
it 'returns the block result' do
|
|
71
|
+
result = described_class.with_sync_mode { 42 }
|
|
72
|
+
expect(result).to eq(42)
|
|
18
73
|
end
|
|
19
74
|
end
|
|
20
75
|
end
|