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 +5 -5
- data/.circleci/config.yml +119 -0
- data/.gitignore +1 -0
- data/.rubocop.yml +11 -0
- data/.travis.yml +5 -5
- data/Gemfile +3 -1
- data/README.md +29 -6
- data/Rakefile +5 -7
- data/lib/rollout/feature.rb +140 -0
- data/lib/rollout/logging.rb +199 -0
- data/lib/rollout/version.rb +3 -1
- data/lib/rollout.rb +73 -159
- data/rollout.gemspec +29 -23
- data/spec/rollout/feature_spec.rb +54 -0
- data/spec/rollout/logging_spec.rb +143 -0
- data/spec/rollout_spec.rb +196 -240
- data/spec/spec_helper.rb +21 -12
- metadata +66 -34
- data/.document +0 -5
- data/Appraisals +0 -7
- data/Gemfile.lock +0 -56
- data/gemfiles/redis_3.gemfile +0 -13
- data/gemfiles/redis_4.gemfile +0 -13
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
|
-
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: f3b0e1f4a4548a07a24f4f7a8153b3a5515447f8bf967163603a02d3e6aebdf1
|
4
|
+
data.tar.gz: d43fa317667dab8ff6269ddc1635e3316aba6d531d1107a68069f1a19e5113dc
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
data/.rubocop.yml
ADDED
data/.travis.yml
CHANGED
data/Gemfile
CHANGED
data/README.md
CHANGED
@@ -2,10 +2,10 @@
|
|
2
2
|
|
3
3
|
Fast feature flags based on Redis.
|
4
4
|
|
5
|
-
[](https://badge.fury.io/rb/rollout)
|
6
|
+
[](https://circleci.com/gh/fetlife/rollout)
|
7
|
+
[](https://codeclimate.com/github/FetLife/rollout)
|
8
|
+
[](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
|
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)
|
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
|
-
|
2
|
-
require "rspec/core/rake_task"
|
1
|
+
# frozen_string_literal: true
|
3
2
|
|
4
|
-
|
3
|
+
require 'rspec/core/rake_task'
|
5
4
|
|
6
|
-
|
7
|
-
|
8
|
-
|
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
|
data/lib/rollout/version.rb
CHANGED