rollout 2.4.6 → 2.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f3e6e9521ed03059b5b673616e985f89231d77d1ca42d8fa7ff00797ef906768
4
- data.tar.gz: ac66f6f5f0da7e03bbf506d09f62d34e6afd7f1ea09b248b5193d74f3b6a720e
3
+ metadata.gz: 58914b07d5596f1db7ce88d65de6e5286519e4b527377b40b7c56ad3ea799323
4
+ data.tar.gz: 4200f92c9784c3d3f6d275a4308714c7b55d35154c97ef01a89a04166e69c041
5
5
  SHA512:
6
- metadata.gz: ccebf1c2024c332cd921a49b400a7ee8e9a5c0ce95cdf59f5e89fb302bef08684cc04ffae30c952e14ffd5acc97d583871ee590aebe6aa2c519cc416afc17b9f
7
- data.tar.gz: fe0a400d1f694a6aefb5b1e7d89afb64a8cb28c07a1f510d01655d724bfb97d5b38e7c60e40669aec5fded5581e760549b53564078018cad92c968b6900753ee
6
+ metadata.gz: 4e3fcddc89ff58fe0d652eed810767eba1a03a4a23312f9bceb174e63348b07fd47592a1504316c3e42169610f24fc78580b846b5649200159dcef6c6ffdbdb7
7
+ data.tar.gz: 02cc8771d302d5e0b33fb370d6a9ed063f5b7d05406f4ec57689f2196921e5ec4e1382b1a1fd175d7804e316f98538d0e2809e6ad7fe25847e06466dd12cafbc
data/.circleci/config.yml CHANGED
@@ -3,58 +3,64 @@ version: 2.1
3
3
  workflows:
4
4
  main:
5
5
  jobs:
6
+ - ruby33
7
+ - ruby31
8
+ - ruby32
9
+ - ruby30
6
10
  - ruby27
7
11
  - ruby26
8
12
  - ruby25
9
13
  - ruby24
10
- - ruby23
11
14
 
12
15
  executors:
16
+ ruby33:
17
+ docker:
18
+ - image: cimg/ruby:3.3
19
+ - image: cimg/redis:7.2
20
+ ruby32:
21
+ docker:
22
+ - image: cimg/ruby:3.2
23
+ - image: cimg/redis:7.2
24
+ ruby31:
25
+ docker:
26
+ - image: cimg/ruby:3.1
27
+ - image: cimg/redis:7.2
28
+ ruby30:
29
+ docker:
30
+ - image: cimg/ruby:3.0
31
+ - image: cimg/redis:7.2
13
32
  ruby27:
14
33
  docker:
15
- - image: circleci/ruby:2.7
16
- - image: circleci/redis:alpine
34
+ - image: cimg/ruby:2.7
35
+ - image: cimg/redis:7.2
17
36
  ruby26:
18
37
  docker:
19
- - image: circleci/ruby:2.6
20
- - image: circleci/redis:alpine
38
+ - image: cimg/ruby:2.7
39
+ - image: cimg/redis:7.2
21
40
  ruby25:
22
41
  docker:
23
- - image: circleci/ruby:2.5
24
- - image: circleci/redis:alpine
42
+ - image: cimg/ruby:2.7
43
+ - image: cimg/redis:7.2
25
44
  ruby24:
26
45
  docker:
27
- - image: circleci/ruby:2.4
28
- - image: circleci/redis:alpine
29
- ruby23:
30
- docker:
31
- - image: circleci/ruby:2.3
32
- - image: circleci/redis:alpine
46
+ - image: cimg/ruby:2.4
47
+ - image: cimg/redis:7.2
33
48
 
34
49
  commands:
35
50
  test:
36
51
  steps:
37
- - restore_cache:
38
- keys:
39
- - bundler-{{ checksum "Gemfile.lock" }}
40
-
41
52
  - run:
42
53
  name: Bundle Install
43
54
  command: bundle check --path vendor/bundle || bundle install
44
55
 
45
- - save_cache:
46
- key: bundler-{{ checksum "Gemfile.lock" }}
47
- paths:
48
- - vendor/bundle
49
-
50
56
  - run:
51
57
  name: Run rspec
52
58
  command: |
53
59
  bundle exec rspec --format documentation --format RspecJunitFormatter --out test_results/rspec.xml
54
60
 
55
61
  jobs:
56
- ruby27:
57
- executor: ruby27
62
+ ruby33:
63
+ executor: ruby33
58
64
  steps:
59
65
  - checkout
60
66
  - test
@@ -70,26 +76,44 @@ jobs:
70
76
  - store_test_results:
71
77
  path: test_results
72
78
 
73
- ruby26:
74
- executor: ruby26
79
+ ruby32:
80
+ executor: ruby30
75
81
  steps:
76
82
  - checkout
77
83
  - test
78
84
 
79
- ruby25:
80
- executor: ruby25
85
+ ruby31:
86
+ executor: ruby30
81
87
  steps:
82
88
  - checkout
83
89
  - test
84
90
 
85
- ruby24:
86
- executor: ruby24
91
+ ruby30:
92
+ executor: ruby30
87
93
  steps:
88
94
  - checkout
89
95
  - test
90
96
 
91
- ruby23:
92
- executor: ruby23
97
+ ruby27:
98
+ executor: ruby27
99
+ steps:
100
+ - checkout
101
+ - test
102
+
103
+ ruby26:
104
+ executor: ruby27
105
+ steps:
106
+ - checkout
107
+ - test
108
+
109
+ ruby25:
110
+ executor: ruby27
111
+ steps:
112
+ - checkout
113
+ - test
114
+
115
+ ruby24:
116
+ executor: ruby24
93
117
  steps:
94
118
  - checkout
95
119
  - test
data/README.md CHANGED
@@ -28,7 +28,7 @@ or even simpler
28
28
 
29
29
  ```ruby
30
30
  require 'redis'
31
- $rollout = Rollout.new(Redis.current) # Will use REDIS_URL env var or default redis url
31
+ $rollout = Rollout.new($redis) # Will use REDIS_URL env var or default redis url
32
32
  ```
33
33
 
34
34
 
@@ -161,6 +161,15 @@ deactivate them automatically when a threshold is reached to prevent service
161
161
  failures from cascading. See https://github.com/jamesgolick/degrade for the
162
162
  failure detection code.
163
163
 
164
+ ## Check Rollout Feature
165
+
166
+ You can inspect the state of your feature using:
167
+
168
+ ```ruby
169
+ >> $rollout.get(:chat)
170
+ => #<Rollout::Feature:0x00007f99fa4ec528 @data={}, @groups=[:caretakers], @name=:chat, @options={}, @percentage=0.05, @users=["1"]>
171
+ ```
172
+
164
173
  ## Namespacing
165
174
 
166
175
  Rollout separates its keys from other keys in the data store using the
@@ -180,6 +189,7 @@ This example would use the "development:feature:chat:groups" key.
180
189
 
181
190
  ## Frontend / UI
182
191
 
192
+ * [rollout-ui](https://github.com/fetlife/rollout-ui)
183
193
  * [Rollout-Dashboard](https://github.com/fiverr/rollout_dashboard/)
184
194
 
185
195
  ## Implementations in other languages
@@ -188,6 +198,7 @@ This example would use the "development:feature:chat:groups" key.
188
198
  * PHP: https://github.com/opensoft/rollout
189
199
  * Clojure: https://github.com/yeller/shoutout
190
200
  * Perl: https://metacpan.org/pod/Toggle
201
+ * Golang: https://github.com/SalesLoft/gorollout
191
202
 
192
203
 
193
204
  ## Contributors
@@ -5,12 +5,13 @@ class Rollout
5
5
  attr_accessor :groups, :users, :percentage, :data
6
6
  attr_reader :name, :options
7
7
 
8
- def initialize(name, string = nil, opts = {})
9
- @options = opts
10
- @name = name
8
+ def initialize(name, rollout:, state: nil, options: {})
9
+ @name = name
10
+ @rollout = rollout
11
+ @options = options
11
12
 
12
- if string
13
- raw_percentage, raw_users, raw_groups, raw_data = string.split('|', 4)
13
+ if state
14
+ raw_percentage, raw_users, raw_groups, raw_data = state.split('|', 4)
14
15
  @percentage = raw_percentage.to_f
15
16
  @users = users_from_string(raw_users)
16
17
  @groups = groups_from_string(raw_groups)
@@ -48,12 +49,12 @@ class Rollout
48
49
  @data = {}
49
50
  end
50
51
 
51
- def active?(rollout, user)
52
+ def active?(user)
52
53
  if user
53
54
  id = user_id(user)
54
55
  user_in_percentage?(id) ||
55
56
  user_in_active_users?(id) ||
56
- user_in_active_group?(user, rollout)
57
+ user_in_active_group?(user)
57
58
  else
58
59
  @percentage == 100
59
60
  end
@@ -67,10 +68,19 @@ class Rollout
67
68
  {
68
69
  percentage: @percentage,
69
70
  groups: @groups,
70
- users: @users
71
+ users: @users,
72
+ data: @data,
71
73
  }
72
74
  end
73
75
 
76
+ def deep_clone
77
+ c = self.clone
78
+ c.instance_variable_set('@rollout', nil)
79
+ c = Marshal.load(Marshal.dump(c))
80
+ c.instance_variable_set('@rollot', @rollout)
81
+ c
82
+ end
83
+
74
84
  private
75
85
 
76
86
  def user_id(user)
@@ -97,9 +107,9 @@ class Rollout
97
107
  end
98
108
  end
99
109
 
100
- def user_in_active_group?(user, rollout)
110
+ def user_in_active_group?(user)
101
111
  @groups.any? do |g|
102
- rollout.active_in_group?(g, user)
112
+ @rollout.active_in_group?(g, user)
103
113
  end
104
114
  end
105
115
 
@@ -0,0 +1,199 @@
1
+ class Rollout
2
+ module Logging
3
+ def self.extended(rollout)
4
+ options = rollout.options[:logging]
5
+ options = options.is_a?(Hash) ? options.dup : {}
6
+ options[:storage] ||= rollout.storage
7
+
8
+ logger = Logger.new(**options)
9
+
10
+ rollout.add_observer(logger, :log)
11
+ rollout.define_singleton_method(:logging) do
12
+ logger
13
+ end
14
+ end
15
+
16
+ class Event
17
+ attr_reader :feature, :name, :data, :context, :created_at
18
+
19
+ def self.from_raw(value, score)
20
+ hash = JSON.parse(value, symbolize_names: true)
21
+
22
+ new(**hash.merge(created_at: Time.at(-score.to_f / 1_000_000)))
23
+ end
24
+
25
+ def initialize(feature: nil, name:, data:, context: {}, created_at:)
26
+ @feature = feature
27
+ @name = name
28
+ @data = data
29
+ @context = context
30
+ @created_at = created_at
31
+ end
32
+
33
+ def timestamp
34
+ (@created_at.to_f * 1_000_000).to_i
35
+ end
36
+
37
+ def serialize
38
+ JSON.dump(
39
+ feature: @feature,
40
+ name: @name,
41
+ data: @data,
42
+ context: @context,
43
+ created_at: @created_at,
44
+ )
45
+ end
46
+
47
+ def ==(other)
48
+ feature == other.feature \
49
+ && name == other.name \
50
+ && data == other.data \
51
+ && created_at == other.created_at
52
+ end
53
+ end
54
+
55
+ class Logger
56
+ def initialize(storage: nil, history_length: 50, global: false)
57
+ @history_length = history_length
58
+ @storage = storage
59
+ @global = global
60
+ end
61
+
62
+ def updated_at(feature_name)
63
+ storage_key = events_storage_key(feature_name)
64
+ _, score = @storage.zrange(storage_key, 0, 0, with_scores: true).first
65
+ Time.at(-score.to_f / 1_000_000) if score
66
+ end
67
+
68
+ def last_event(feature_name)
69
+ storage_key = events_storage_key(feature_name)
70
+ value = @storage.zrange(storage_key, 0, 0, with_scores: true).first
71
+ Event.from_raw(*value) if value
72
+ end
73
+
74
+ def events(feature_name)
75
+ storage_key = events_storage_key(feature_name)
76
+ @storage
77
+ .zrange(storage_key, 0, -1, with_scores: true)
78
+ .map { |v| Event.from_raw(*v) }
79
+ .reverse
80
+ end
81
+
82
+ def global_events
83
+ @storage
84
+ .zrange(global_events_storage_key, 0, -1, with_scores: true)
85
+ .map { |v| Event.from_raw(*v) }
86
+ .reverse
87
+ end
88
+
89
+ def delete(feature_name)
90
+ storage_key = events_storage_key(feature_name)
91
+ @storage.del(storage_key)
92
+ end
93
+
94
+ def update(before, after)
95
+ before_hash = before.to_hash
96
+ before_hash.delete(:data).each do |k, v|
97
+ before_hash["data.#{k}"] = v
98
+ end
99
+ after_hash = after.to_hash
100
+ after_hash.delete(:data).each do |k, v|
101
+ after_hash["data.#{k}"] = v
102
+ end
103
+
104
+ keys = before_hash.keys | after_hash.keys
105
+ change = { before: {}, after: {} }
106
+ changed_count = 0
107
+
108
+ keys.each do |key|
109
+ next if before_hash[key] == after_hash[key]
110
+
111
+ change[:before][key] = before_hash[key]
112
+ change[:after][key] = after_hash[key]
113
+
114
+ changed_count += 1
115
+ end
116
+
117
+ return if changed_count == 0
118
+
119
+ event = Event.new(
120
+ feature: after.name,
121
+ name: :update,
122
+ data: change,
123
+ context: current_context,
124
+ created_at: Time.now,
125
+ )
126
+
127
+ storage_key = events_storage_key(after.name)
128
+
129
+ @storage.zadd(storage_key, -event.timestamp, event.serialize)
130
+ @storage.zremrangebyrank(storage_key, @history_length, -1)
131
+
132
+ if @global
133
+ @storage.zadd(global_events_storage_key, -event.timestamp, event.serialize)
134
+ @storage.zremrangebyrank(global_events_storage_key, @history_length, -1)
135
+ end
136
+ end
137
+
138
+ def log(event, *args)
139
+ return unless logging_enabled?
140
+
141
+ unless respond_to?(event)
142
+ raise ArgumentError, "Invalid log event: #{event}"
143
+ end
144
+
145
+ expected_arity = method(event).arity
146
+ unless args.count == expected_arity
147
+ raise(
148
+ ArgumentError,
149
+ "Invalid number of arguments for event '#{event}': expected #{expected_arity} but got #{args.count}",
150
+ )
151
+ end
152
+
153
+ public_send(event, *args)
154
+ end
155
+
156
+ CONTEXT_THREAD_KEY = :rollout_logging_context
157
+ WITHOUT_THREAD_KEY = :rollout_logging_disabled
158
+
159
+ def with_context(context)
160
+ raise ArgumentError, "context must be a Hash" unless context.is_a?(Hash)
161
+ raise ArgumentError, "block is required" unless block_given?
162
+
163
+ Thread.current[CONTEXT_THREAD_KEY] = context
164
+ yield
165
+ ensure
166
+ Thread.current[CONTEXT_THREAD_KEY] = nil
167
+ end
168
+
169
+ def current_context
170
+ Thread.current[CONTEXT_THREAD_KEY] || {}
171
+ end
172
+
173
+ def without
174
+ Thread.current[WITHOUT_THREAD_KEY] = true
175
+ yield
176
+ ensure
177
+ Thread.current[WITHOUT_THREAD_KEY] = nil
178
+ end
179
+
180
+ def logging_enabled?
181
+ !Thread.current[WITHOUT_THREAD_KEY]
182
+ end
183
+
184
+ private
185
+
186
+ def global_events_storage_key
187
+ "feature:_global_:logging:events"
188
+ end
189
+
190
+ def events_storage_key(feature_name)
191
+ "feature:#{feature_name}:logging:events"
192
+ end
193
+
194
+ def current_timestamp
195
+ (Time.now.to_f * 1_000_000).to_i
196
+ end
197
+ end
198
+ end
199
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Rollout
4
- VERSION = '2.4.6'
4
+ VERSION = '2.6.0'
5
5
  end
data/lib/rollout.rb CHANGED
@@ -1,18 +1,30 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'rollout/feature'
4
+ require 'rollout/logging'
4
5
  require 'rollout/version'
5
6
  require 'zlib'
6
7
  require 'set'
7
8
  require 'json'
9
+ require 'observer'
8
10
 
9
11
  class Rollout
12
+ include Observable
13
+
10
14
  RAND_BASE = (2**32 - 1) / 100.0
11
15
 
16
+ attr_reader :options, :storage
17
+
12
18
  def initialize(storage, opts = {})
13
19
  @storage = storage
14
20
  @options = opts
15
21
  @groups = { all: ->(_user) { true } }
22
+
23
+ extend(Logging) if opts[:logging]
24
+ end
25
+
26
+ def groups
27
+ @groups.keys
16
28
  end
17
29
 
18
30
  def activate(feature)
@@ -30,6 +42,10 @@ class Rollout
30
42
  features.delete(feature.to_s)
31
43
  @storage.set(features_key, features.join(','))
32
44
  @storage.del(key(feature))
45
+
46
+ if respond_to?(:logging)
47
+ logging.delete(feature)
48
+ end
33
49
  end
34
50
 
35
51
  def set(feature, desired_state)
@@ -91,7 +107,7 @@ class Rollout
91
107
 
92
108
  def active?(feature, user = nil)
93
109
  feature = get(feature)
94
- feature.active?(self, user)
110
+ feature.active?(user)
95
111
  end
96
112
 
97
113
  def user_in_active_users?(feature, user = nil)
@@ -122,7 +138,7 @@ class Rollout
122
138
 
123
139
  def get(feature)
124
140
  string = @storage.get(key(feature))
125
- Feature.new(feature, string, @options)
141
+ Feature.new(feature, state: string, rollout: self, options: @options)
126
142
  end
127
143
 
128
144
  def set_feature_data(feature, data)
@@ -141,7 +157,13 @@ class Rollout
141
157
  return [] if features.empty?
142
158
 
143
159
  feature_keys = features.map { |feature| key(feature) }
144
- @storage.mget(*feature_keys).map.with_index { |string, index| Feature.new(features[index], string, @options) }
160
+
161
+ @storage
162
+ .mget(*feature_keys)
163
+ .map
164
+ .with_index do |string, index|
165
+ Feature.new(features[index], state: string, rollout: self, options: @options)
166
+ end
145
167
  end
146
168
 
147
169
  def features
@@ -150,13 +172,13 @@ class Rollout
150
172
 
151
173
  def feature_states(user = nil)
152
174
  multi_get(*features).each_with_object({}) do |f, hash|
153
- hash[f.name] = f.active?(self, user)
175
+ hash[f.name] = f.active?(user)
154
176
  end
155
177
  end
156
178
 
157
179
  def active_features(user = nil)
158
180
  multi_get(*features).select do |f|
159
- f.active?(self, user)
181
+ f.active?(user)
160
182
  end.map(&:name)
161
183
  end
162
184
 
@@ -179,6 +201,21 @@ class Rollout
179
201
  end
180
202
  end
181
203
 
204
+ def with_feature(feature)
205
+ f = get(feature)
206
+
207
+ if count_observers > 0
208
+ before = f.deep_clone
209
+ yield(f)
210
+ save(f)
211
+ changed
212
+ notify_observers(:update, before, f)
213
+ else
214
+ yield(f)
215
+ save(f)
216
+ end
217
+ end
218
+
182
219
  private
183
220
 
184
221
  def key(name)
@@ -189,12 +226,6 @@ class Rollout
189
226
  'feature:__features__'
190
227
  end
191
228
 
192
- def with_feature(feature)
193
- f = get(feature)
194
- yield(f)
195
- save(f)
196
- end
197
-
198
229
  def save(feature)
199
230
  @storage.set(key(feature.name), feature.serialize)
200
231
  @storage.set(features_key, (features | [feature.name.to_sym]).join(','))
data/rollout.gemspec CHANGED
@@ -20,11 +20,11 @@ Gem::Specification.new do |spec|
20
20
 
21
21
  spec.required_ruby_version = '>= 2.3'
22
22
 
23
- spec.add_dependency 'redis', '~> 4.0'
23
+ spec.add_dependency 'redis', '>= 4.0', '< 6'
24
24
 
25
25
  spec.add_development_dependency 'bundler', '>= 1.17'
26
26
  spec.add_development_dependency 'pry'
27
- spec.add_development_dependency 'rspec', '~> 3.0'
27
+ spec.add_development_dependency 'rspec', '~> 3.13'
28
28
  spec.add_development_dependency 'rspec_junit_formatter', '~> 0.4'
29
29
  spec.add_development_dependency 'rubocop', '~> 0.71'
30
30
  spec.add_development_dependency 'simplecov', '0.17'
@@ -0,0 +1,54 @@
1
+ require "spec_helper"
2
+
3
+ describe "Rollout::Feature" do
4
+ let(:rollout) { Rollout.new($redis) }
5
+
6
+ describe "#add_user" do
7
+ it "ids a user using id_user_by" do
8
+ user = double("User", email: "test@test.com")
9
+ feature = Rollout::Feature.new(:chat, state: nil, rollout: rollout, options: { id_user_by: :email })
10
+ feature.add_user(user)
11
+ expect(user).to have_received :email
12
+ end
13
+ end
14
+
15
+ describe "#initialize" do
16
+ describe "when string does not exist" do
17
+ it 'clears feature attributes when string is not given' do
18
+ feature = Rollout::Feature.new(:chat, rollout: rollout)
19
+ expect(feature.groups).to be_empty
20
+ expect(feature.users).to be_empty
21
+ expect(feature.percentage).to eq 0
22
+ expect(feature.data).to eq({})
23
+ end
24
+
25
+ it 'clears feature attributes when string is nil' do
26
+ feature = Rollout::Feature.new(:chat, state: nil, rollout: rollout)
27
+ expect(feature.groups).to be_empty
28
+ expect(feature.users).to be_empty
29
+ expect(feature.percentage).to eq 0
30
+ expect(feature.data).to eq({})
31
+ end
32
+
33
+ it 'clears feature attributes when string is empty string' do
34
+ feature = Rollout::Feature.new(:chat, state: "", rollout: rollout)
35
+ expect(feature.groups).to be_empty
36
+ expect(feature.users).to be_empty
37
+ expect(feature.percentage).to eq 0
38
+ expect(feature.data).to eq({})
39
+ end
40
+
41
+ describe "when there is no data" do
42
+ it 'sets @data to empty hash' do
43
+ feature = Rollout::Feature.new(:chat, state: "0||", rollout: rollout)
44
+ expect(feature.data).to eq({})
45
+ end
46
+
47
+ it 'sets @data to empty hash' do
48
+ feature = Rollout::Feature.new(:chat, state: "||| ", rollout: rollout)
49
+ expect(feature.data).to eq({})
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end