rollout 2.4.3 → 2.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: c11b1c464e66f315ad7ee924ea7408a5c4bf2a6f
4
- data.tar.gz: 16da6d26818429e62e4783533df17d9022380a46
2
+ SHA256:
3
+ metadata.gz: f3b0e1f4a4548a07a24f4f7a8153b3a5515447f8bf967163603a02d3e6aebdf1
4
+ data.tar.gz: d43fa317667dab8ff6269ddc1635e3316aba6d531d1107a68069f1a19e5113dc
5
5
  SHA512:
6
- metadata.gz: 4b7b1416e6ba291b67dbce4b5d67db30bbe494fbb31791f6435efe3280607abf17d64cf96210bbe113c6c9948ca8d50c25c0547b9870d0f652033b69cf412b00
7
- data.tar.gz: 19e0b2bd04907cfe2f1b400ccb047b8320b35dddf85f1b76d607b5491340f0b3a63a20fdb2f30af138579a8e016accc4aa88549d32c4136c8a003fe2997ea318
6
+ metadata.gz: f356e92d026c71c207e9415481a547741b7d8e067e90bd27806d6f7f13e0657de93b41105666ffe91b8b6e83f37b7e512b09ea179f63da5ef07bdf5e5d405a77
7
+ data.tar.gz: 4014f08455bd9ecf3100d16cab73ff3bde34ffb4c40478e2dec0f2d6a535db48915d9aedd80ec3bcee3858c02d33f4669afd9d8ec5b5888cbe769ea4a8807f02
@@ -0,0 +1,119 @@
1
+ version: 2.1
2
+
3
+ workflows:
4
+ main:
5
+ jobs:
6
+ - ruby33
7
+ - ruby31
8
+ - ruby32
9
+ - ruby30
10
+ - ruby27
11
+ - ruby26
12
+ - ruby25
13
+ - ruby24
14
+
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
32
+ ruby27:
33
+ docker:
34
+ - image: cimg/ruby:2.7
35
+ - image: cimg/redis:7.2
36
+ ruby26:
37
+ docker:
38
+ - image: cimg/ruby:2.7
39
+ - image: cimg/redis:7.2
40
+ ruby25:
41
+ docker:
42
+ - image: cimg/ruby:2.7
43
+ - image: cimg/redis:7.2
44
+ ruby24:
45
+ docker:
46
+ - image: cimg/ruby:2.4
47
+ - image: cimg/redis:7.2
48
+
49
+ commands:
50
+ test:
51
+ steps:
52
+ - run:
53
+ name: Bundle Install
54
+ command: bundle check --path vendor/bundle || bundle install
55
+
56
+ - run:
57
+ name: Run rspec
58
+ command: |
59
+ bundle exec rspec --format documentation --format RspecJunitFormatter --out test_results/rspec.xml
60
+
61
+ jobs:
62
+ ruby33:
63
+ executor: ruby33
64
+ steps:
65
+ - checkout
66
+ - test
67
+
68
+ - run:
69
+ name: Report Test Coverage
70
+ command: |
71
+ wget https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 -O cc-test-reporter
72
+ chmod +x cc-test-reporter
73
+ ./cc-test-reporter format-coverage -t simplecov -o coverage/codeclimate.json coverage/.resultset.json
74
+ ./cc-test-reporter upload-coverage -i coverage/codeclimate.json
75
+
76
+ - store_test_results:
77
+ path: test_results
78
+
79
+ ruby32:
80
+ executor: ruby30
81
+ steps:
82
+ - checkout
83
+ - test
84
+
85
+ ruby31:
86
+ executor: ruby30
87
+ steps:
88
+ - checkout
89
+ - test
90
+
91
+ ruby30:
92
+ executor: ruby30
93
+ steps:
94
+ - checkout
95
+ - test
96
+
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
117
+ steps:
118
+ - checkout
119
+ - test
data/.gitignore CHANGED
@@ -19,5 +19,6 @@ rdoc
19
19
  pkg
20
20
  *.gem
21
21
  gemfiles/*.lock
22
+ .rspec_status
22
23
 
23
24
  ## PROJECT::SPECIFIC
data/.rubocop.yml ADDED
@@ -0,0 +1,11 @@
1
+ AllCops:
2
+ Exclude:
3
+ - 'spec/**/*'
4
+
5
+ Metrics/LineLength:
6
+ Max: 120
7
+ Metrics/MethodLength:
8
+ Max: 20
9
+
10
+ Style/TrailingCommaInArguments:
11
+ EnforcedStyleForMultiline: comma
data/.travis.yml CHANGED
@@ -4,13 +4,13 @@ sudo: false
4
4
  services:
5
5
  - redis-server
6
6
  rvm:
7
- - 2.4.1
8
- - 2.3.1
7
+ - 2.6
8
+ - 2.5
9
+ - 2.4
10
+ - 2.3
9
11
  - 2.2
10
12
  - 2.1
11
- - 2.0.0
12
- - 1.9.3
13
- - jruby-19mode
13
+ - 2.0
14
14
  env:
15
15
  - USE_REAL_REDIS=true
16
16
  gemfile:
data/Gemfile CHANGED
@@ -1,3 +1,5 @@
1
- source "https://rubygems.org"
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
2
4
 
3
5
  gemspec
data/README.md CHANGED
@@ -2,10 +2,10 @@
2
2
 
3
3
  Fast feature flags based on Redis.
4
4
 
5
- [![Build Status](https://travis-ci.org/fetlife/rollout.svg?branch=master)](https://travis-ci.org/fetlife/rollout)
6
- [![Code Climate](https://codeclimate.com/github/FetLife/rollout/badges/gpa.svg)](https://codeclimate.com/github/fetlife/rollout)
7
- [![Test Coverage](https://codeclimate.com/github/FetLife/rollout/badges/coverage.svg)](https://codeclimate.com/github/fetlife/rollout/coverage)
8
- [![Dependency Status](https://gemnasium.com/FetLife/rollout.svg)](https://gemnasium.com/fetlife/rollout)
5
+ [![Gem Version](https://badge.fury.io/rb/rollout.svg)](https://badge.fury.io/rb/rollout)
6
+ [![CircleCI](https://circleci.com/gh/fetlife/rollout.svg?style=svg)](https://circleci.com/gh/fetlife/rollout)
7
+ [![Code Climate](https://codeclimate.com/github/FetLife/rollout/badges/gpa.svg)](https://codeclimate.com/github/FetLife/rollout)
8
+ [![Test Coverage](https://codeclimate.com/github/FetLife/rollout/badges/coverage.svg)](https://codeclimate.com/github/FetLife/rollout/coverage)
9
9
 
10
10
  ## Install it
11
11
 
@@ -20,10 +20,18 @@ Initialize a rollout object. I assign it to a global var.
20
20
  ```ruby
21
21
  require 'redis'
22
22
 
23
- $redis = Redis.new
23
+ $redis = Redis.new
24
24
  $rollout = Rollout.new($redis)
25
25
  ```
26
26
 
27
+ or even simpler
28
+
29
+ ```ruby
30
+ require 'redis'
31
+ $rollout = Rollout.new($redis) # Will use REDIS_URL env var or default redis url
32
+ ```
33
+
34
+
27
35
  Update data specific to a feature:
28
36
 
29
37
  ```ruby
@@ -71,6 +79,9 @@ Deactivate groups like this:
71
79
  $rollout.deactivate_group(:chat, :all)
72
80
  ```
73
81
 
82
+ Groups need to be defined every time your app starts. The logic is not persisted
83
+ anywhere.
84
+
74
85
  ## Specific Users
75
86
 
76
87
  You might want to let a specific user into a beta test or something. If that
@@ -98,7 +109,7 @@ $rollout.activate_percentage(:chat, 20)
98
109
  The algorithm for determining which users get let in is this:
99
110
 
100
111
  ```ruby
101
- CRC32(user.id) % 100_000 < percentage * 1_000
112
+ CRC32(user.id) < (2**32 - 1) / 100.0 * percentage
102
113
  ```
103
114
 
104
115
  So, for 20%, users 0, 1, 10, 11, 20, 21, etc would be allowed in. Those users
@@ -150,6 +161,15 @@ deactivate them automatically when a threshold is reached to prevent service
150
161
  failures from cascading. See https://github.com/jamesgolick/degrade for the
151
162
  failure detection code.
152
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
+
153
173
  ## Namespacing
154
174
 
155
175
  Rollout separates its keys from other keys in the data store using the
@@ -169,6 +189,7 @@ This example would use the "development:feature:chat:groups" key.
169
189
 
170
190
  ## Frontend / UI
171
191
 
192
+ * [rollout-ui](https://github.com/fetlife/rollout-ui)
172
193
  * [Rollout-Dashboard](https://github.com/fiverr/rollout_dashboard/)
173
194
 
174
195
  ## Implementations in other languages
@@ -176,6 +197,8 @@ This example would use the "development:feature:chat:groups" key.
176
197
  * Python: https://github.com/asenchi/proclaim
177
198
  * PHP: https://github.com/opensoft/rollout
178
199
  * Clojure: https://github.com/yeller/shoutout
200
+ * Perl: https://metacpan.org/pod/Toggle
201
+ * Golang: https://github.com/SalesLoft/gorollout
179
202
 
180
203
 
181
204
  ## Contributors
data/Rakefile CHANGED
@@ -1,9 +1,7 @@
1
- begin
2
- require "rspec/core/rake_task"
1
+ # frozen_string_literal: true
3
2
 
4
- RSpec::Core::RakeTask.new(:spec)
3
+ require 'rspec/core/rake_task'
5
4
 
6
- task default: :spec
7
- rescue LoadError
8
- # no rspec available
9
- end
5
+ RSpec::Core::RakeTask.new(:spec)
6
+
7
+ task default: :spec
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Rollout
4
+ class Feature
5
+ attr_accessor :groups, :users, :percentage, :data
6
+ attr_reader :name, :options
7
+
8
+ def initialize(name, rollout:, state: nil, options: {})
9
+ @name = name
10
+ @rollout = rollout
11
+ @options = options
12
+
13
+ if state
14
+ raw_percentage, raw_users, raw_groups, raw_data = state.split('|', 4)
15
+ @percentage = raw_percentage.to_f
16
+ @users = users_from_string(raw_users)
17
+ @groups = groups_from_string(raw_groups)
18
+ @data = raw_data.nil? || raw_data.strip.empty? ? {} : JSON.parse(raw_data)
19
+ else
20
+ clear
21
+ end
22
+ end
23
+
24
+ def serialize
25
+ "#{@percentage}|#{@users.to_a.join(',')}|#{@groups.to_a.join(',')}|#{serialize_data}"
26
+ end
27
+
28
+ def add_user(user)
29
+ id = user_id(user)
30
+ @users << id unless @users.include?(id)
31
+ end
32
+
33
+ def remove_user(user)
34
+ @users.delete(user_id(user))
35
+ end
36
+
37
+ def add_group(group)
38
+ @groups << group.to_sym unless @groups.include?(group.to_sym)
39
+ end
40
+
41
+ def remove_group(group)
42
+ @groups.delete(group.to_sym)
43
+ end
44
+
45
+ def clear
46
+ @groups = groups_from_string('')
47
+ @users = users_from_string('')
48
+ @percentage = 0
49
+ @data = {}
50
+ end
51
+
52
+ def active?(user)
53
+ if user
54
+ id = user_id(user)
55
+ user_in_percentage?(id) ||
56
+ user_in_active_users?(id) ||
57
+ user_in_active_group?(user)
58
+ else
59
+ @percentage == 100
60
+ end
61
+ end
62
+
63
+ def user_in_active_users?(user)
64
+ @users.include?(user_id(user))
65
+ end
66
+
67
+ def to_hash
68
+ {
69
+ percentage: @percentage,
70
+ groups: @groups,
71
+ users: @users,
72
+ data: @data,
73
+ }
74
+ end
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
+
84
+ private
85
+
86
+ def user_id(user)
87
+ if user.is_a?(Integer) || user.is_a?(String)
88
+ user.to_s
89
+ else
90
+ user.send(id_user_by).to_s
91
+ end
92
+ end
93
+
94
+ def id_user_by
95
+ @options[:id_user_by] || :id
96
+ end
97
+
98
+ def user_in_percentage?(user)
99
+ Zlib.crc32(user_id_for_percentage(user)) < RAND_BASE * @percentage
100
+ end
101
+
102
+ def user_id_for_percentage(user)
103
+ if @options[:randomize_percentage]
104
+ user_id(user).to_s + @name.to_s
105
+ else
106
+ user_id(user)
107
+ end
108
+ end
109
+
110
+ def user_in_active_group?(user)
111
+ @groups.any? do |g|
112
+ @rollout.active_in_group?(g, user)
113
+ end
114
+ end
115
+
116
+ def serialize_data
117
+ return '' unless @data.is_a? Hash
118
+
119
+ @data.to_json
120
+ end
121
+
122
+ def users_from_string(raw_users)
123
+ users = (raw_users || '').split(',').map(&:to_s)
124
+ if @options[:use_sets]
125
+ users.to_set
126
+ else
127
+ users
128
+ end
129
+ end
130
+
131
+ def groups_from_string(raw_groups)
132
+ groups = (raw_groups || '').split(',').map(&:to_sym)
133
+ if @options[:use_sets]
134
+ groups.to_set
135
+ else
136
+ groups
137
+ end
138
+ end
139
+ end
140
+ end
@@ -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,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  class Rollout
2
- VERSION = "2.4.3"
4
+ VERSION = '2.6.1'
3
5
  end