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.
Files changed (95) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +9 -6
  3. data/.github/workflows/examples.yml +5 -4
  4. data/.github/workflows/release.yml +54 -0
  5. data/.superset/config.json +4 -0
  6. data/CLAUDE.md +93 -0
  7. data/Gemfile +6 -2
  8. data/README.md +4 -3
  9. data/examples/cloud/backoff_policy.rb +1 -1
  10. data/examples/cloud/poll_interval/README.md +111 -0
  11. data/examples/cloud/poll_interval/client.rb +108 -0
  12. data/examples/cloud/poll_interval/server.rb +98 -0
  13. data/examples/expressions.rb +35 -11
  14. data/lib/flipper/adapter.rb +17 -1
  15. data/lib/flipper/adapters/actor_limit.rb +27 -1
  16. data/lib/flipper/adapters/cache_base.rb +21 -3
  17. data/lib/flipper/adapters/dual_write.rb +6 -2
  18. data/lib/flipper/adapters/failover.rb +9 -3
  19. data/lib/flipper/adapters/failsafe.rb +2 -2
  20. data/lib/flipper/adapters/http/client.rb +15 -4
  21. data/lib/flipper/adapters/http/error.rb +1 -1
  22. data/lib/flipper/adapters/http.rb +39 -4
  23. data/lib/flipper/adapters/instrumented.rb +2 -2
  24. data/lib/flipper/adapters/memoizable.rb +3 -3
  25. data/lib/flipper/adapters/memory.rb +1 -1
  26. data/lib/flipper/adapters/poll.rb +15 -0
  27. data/lib/flipper/adapters/pstore.rb +1 -1
  28. data/lib/flipper/adapters/strict.rb +30 -0
  29. data/lib/flipper/adapters/sync/feature_synchronizer.rb +5 -1
  30. data/lib/flipper/adapters/sync/synchronizer.rb +13 -5
  31. data/lib/flipper/adapters/sync.rb +7 -3
  32. data/lib/flipper/cli.rb +51 -0
  33. data/lib/flipper/cloud/configuration.rb +14 -6
  34. data/lib/flipper/cloud/dsl.rb +2 -2
  35. data/lib/flipper/cloud/middleware.rb +1 -1
  36. data/lib/flipper/cloud/migrate.rb +71 -0
  37. data/lib/flipper/cloud/telemetry/backoff_policy.rb +6 -3
  38. data/lib/flipper/cloud/telemetry/submitter.rb +3 -1
  39. data/lib/flipper/cloud/telemetry.rb +3 -3
  40. data/lib/flipper/cloud.rb +1 -0
  41. data/lib/flipper/dsl.rb +1 -1
  42. data/lib/flipper/export.rb +0 -2
  43. data/lib/flipper/expressions/all.rb +0 -2
  44. data/lib/flipper/expressions/feature_enabled.rb +34 -0
  45. data/lib/flipper/expressions/time.rb +8 -1
  46. data/lib/flipper/feature.rb +8 -1
  47. data/lib/flipper/gate.rb +1 -1
  48. data/lib/flipper/gates/expression.rb +2 -2
  49. data/lib/flipper/instrumentation/log_subscriber.rb +1 -2
  50. data/lib/flipper/instrumentation/statsd.rb +4 -2
  51. data/lib/flipper/instrumentation/subscriber.rb +0 -4
  52. data/lib/flipper/metadata.rb +1 -0
  53. data/lib/flipper/poller.rb +54 -11
  54. data/lib/flipper/version.rb +1 -1
  55. data/lib/flipper.rb +17 -1
  56. data/lib/generators/flipper/setup_generator.rb +5 -0
  57. data/lib/generators/flipper/templates/initializer.rb +45 -0
  58. data/spec/flipper/adapter_spec.rb +20 -0
  59. data/spec/flipper/adapters/actor_limit_spec.rb +55 -0
  60. data/spec/flipper/adapters/dual_write_spec.rb +13 -0
  61. data/spec/flipper/adapters/failover_spec.rb +12 -0
  62. data/spec/flipper/adapters/http_spec.rb +241 -0
  63. data/spec/flipper/adapters/poll_spec.rb +41 -0
  64. data/spec/flipper/adapters/strict_spec.rb +62 -4
  65. data/spec/flipper/adapters/sync/feature_synchronizer_spec.rb +12 -0
  66. data/spec/flipper/adapters/sync/synchronizer_spec.rb +87 -0
  67. data/spec/flipper/adapters/sync_spec.rb +13 -0
  68. data/spec/flipper/cli_spec.rb +51 -0
  69. data/spec/flipper/cloud/configuration_spec.rb +6 -0
  70. data/spec/flipper/cloud/dsl_spec.rb +11 -3
  71. data/spec/flipper/cloud/middleware_spec.rb +34 -16
  72. data/spec/flipper/cloud/migrate_spec.rb +160 -0
  73. data/spec/flipper/cloud/telemetry/backoff_policy_spec.rb +3 -3
  74. data/spec/flipper/cloud/telemetry/submitter_spec.rb +4 -4
  75. data/spec/flipper/cloud/telemetry_spec.rb +6 -6
  76. data/spec/flipper/cloud_spec.rb +9 -4
  77. data/spec/flipper/dsl_spec.rb +0 -3
  78. data/spec/flipper/engine_spec.rb +3 -2
  79. data/spec/flipper/expressions/time_spec.rb +16 -0
  80. data/spec/flipper/feature_spec.rb +22 -11
  81. data/spec/flipper/gates/expression_spec.rb +82 -0
  82. data/spec/flipper/instrumentation/log_subscriber_spec.rb +1 -0
  83. data/spec/flipper/instrumentation/statsd_subscriber_spec.rb +1 -1
  84. data/spec/flipper/middleware/memoizer_spec.rb +41 -11
  85. data/spec/flipper/model/active_record_spec.rb +11 -0
  86. data/spec/flipper/poller_spec.rb +347 -4
  87. data/spec/flipper_integration_spec.rb +133 -0
  88. data/spec/flipper_spec.rb +7 -2
  89. data/spec/spec_helper.rb +15 -5
  90. data/test_rails/generators/flipper/setup_generator_test.rb +5 -0
  91. data/test_rails/generators/flipper/update_generator_test.rb +1 -1
  92. data/test_rails/helper.rb +3 -0
  93. metadata +17 -111
  94. data/lib/flipper/expressions/duration.rb +0 -28
  95. data/spec/flipper/expressions/duration_spec.rb +0 -43
@@ -10,8 +10,8 @@ module Flipper
10
10
  super Flipper.new(@cloud_configuration.adapter, instrumenter: @cloud_configuration.instrumenter)
11
11
  end
12
12
 
13
- def sync
14
- @cloud_configuration.sync
13
+ def sync(**kwargs)
14
+ @cloud_configuration.sync(**kwargs)
15
15
  end
16
16
 
17
17
  def sync_secret
@@ -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 = 1_000
6
+ MIN_TIMEOUT_MS = 30_000
7
7
 
8
8
  # Private: The default maximum timeout between intervals in milliseconds.
9
- MAX_TIMEOUT_MS = 30_000
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
- [interval, @max_timeout_ms].min
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(10) { submit(body) }
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(2, {
79
- max_queue: 5,
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.to_f
171
+ self.interval = interval
172
172
  end
173
173
  end
174
174
  rescue => error
data/lib/flipper/cloud.rb CHANGED
@@ -4,6 +4,7 @@ require "flipper/middleware/memoizer"
4
4
  require "flipper/cloud/configuration"
5
5
  require "flipper/cloud/dsl"
6
6
  require "flipper/cloud/middleware"
7
+ require "flipper/cloud/migrate"
7
8
 
8
9
  module Flipper
9
10
  module Cloud
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
  #
@@ -1,5 +1,3 @@
1
- require "flipper/adapters/memory"
2
-
3
1
  module Flipper
4
2
  class Export
5
3
  attr_reader :contents, :format, :version
@@ -1,5 +1,3 @@
1
- require "flipper/expression"
2
-
3
1
  module Flipper
4
2
  module Expressions
5
3
  class All
@@ -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
@@ -1,8 +1,15 @@
1
+ require "time"
2
+
1
3
  module Flipper
2
4
  module Expressions
3
5
  class Time
4
6
  def self.call(value)
5
- ::Time.parse(value)
7
+ case value
8
+ when Numeric
9
+ ::Time.at(value).utc
10
+ else
11
+ ::Time.parse(value).utc
12
+ end
6
13
  end
7
14
  end
8
15
  end
@@ -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.flatten.compact.map { |actor| Types::Actor.wrap(actor) }
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
@@ -26,7 +26,7 @@ module Flipper
26
26
  # in subclass.
27
27
  #
28
28
  # Returns true if gate open for any actor, false if not.
29
- def open?(actors, value, options = {})
29
+ def open?(context)
30
30
  false
31
31
  end
32
32
 
@@ -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 /\.flipper$/,
6
- Flipper::Instrumentation::StatsdSubscriber
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
@@ -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
@@ -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 {|_, instance| instance.stop }.clear
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
- if @interval < MINIMUM_POLL_INTERVAL
36
- warn "Flipper::Cloud poll interval must be greater than or equal to #{MINIMUM_POLL_INTERVAL} but was #{@interval}. Setting @interval to #{MINIMUM_POLL_INTERVAL}."
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
- start = Concurrent.monotonic_time
65
+
63
66
  begin
64
67
  sync
65
- rescue => exception
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
- @adapter.import @remote_adapter
76
- @last_synced_at.update { |time| Concurrent.monotonic_time }
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
- rand
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
@@ -1,5 +1,5 @@
1
1
  module Flipper
2
- VERSION = '1.3.2'.freeze
2
+ VERSION = '1.4.1'.freeze
3
3
 
4
4
  REQUIRED_RUBY_VERSION = '2.6'.freeze
5
5
  NEXT_REQUIRED_RUBY_VERSION = '3.0'.freeze
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