consistent_random 1.0.0 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 22c5eb562537bed9919f77d7d5daeafcd33c8279e91c902d66cfc48b07183a19
4
- data.tar.gz: 27000167f87f8b8a02723d8e160d054f59a4d077bf5e22c5834a978e99f6f492
3
+ metadata.gz: 980cb06e9d5c20085243ecb114932f744343830090a6148db527d5c685458c46
4
+ data.tar.gz: 8872129783254ab5cd4ae70f4051ceb5901c4d28852168dfa0e4b229c8541d13
5
5
  SHA512:
6
- metadata.gz: 6c2d988676f767b138d64e44c6f87f97e08058a5c2fd0a7ce528b4728f624d8c887753182b409c9b343ee3ece9d93f681306fcbecbf7967c5e9c524eb4c95b19
7
- data.tar.gz: 7ff4c55d561b0947d7d51d7c96b11e9bf85dcccdc0e11c7f1b6c25923cc3b23696dcdd8ee3069c46c358a81506ab46cc3980a7fc3e3f12929d86c97c7ed19c50
6
+ metadata.gz: 99ad2eb1aaf87bdee2aa1c1052d73c8e9db13e392c9d501c9d8d445644902414a0602ef4f8da3c63199cd55e2da0519bd15dd48b7070bbec59414506d181613c
7
+ data.tar.gz: 76a775637c84fa12fb56bf6437943ffdc59cb7a454ec1fa9af44fda471d614237e61297c18251744fb45d5de2c0d18b8ec84ef1a3603b4e56b16ce954bb4a5e1
data/CHANGELOG.md CHANGED
@@ -4,6 +4,17 @@ All notable changes to this project will be documented in this file.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## 2.0.0
8
+
9
+ ### Changed
10
+
11
+ - `ConsistentRandom#rand` uses a faster hashing algorithm for generating random values. This will make the value consistent across different Ruby versions and platforms.
12
+
13
+ ### Added
14
+
15
+ - Added `ConsistentRandom::SidekiqClientMiddleware` to allow persisting consistent random seeds to Sidekiq jobs so behavior is consistent between when jobs are enqueued and when they are executed.
16
+ - Added `ConsistentRandom::ActiveJob` for hooking into ActiveJob to persist consistent random seeds to jobs.
17
+
7
18
  ## 1.0.0
8
19
 
9
20
  ### Added
data/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # Constant Random
1
+ # Consistent Random
2
2
 
3
3
  [![Continuous Integration](https://github.com/bdurand/consistent_random/actions/workflows/continuous_integration.yml/badge.svg)](https://github.com/bdurand/consistent_random/actions/workflows/continuous_integration.yml)
4
4
  [![Ruby Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://github.com/testdouble/standard)
@@ -6,9 +6,9 @@
6
6
 
7
7
  ## Introduction
8
8
 
9
- This Ruby gem allows you to generate consistent random values tied to a specific name within a defined scope. It ensures that random behavior remains consistent within a particular context, such as handling feature rollouts.
9
+ This Ruby gem allows you to generate consistent random values tied to a specific name within a defined scope. It ensures that random behavior remains consistent within a particular context.
10
10
 
11
- For example, consider rolling out a new feature to a subset of requests, such as enabling the feature for 10% of requests. You want to randomize which requests get the new feature, but ensure that within each request, the feature is consistently enabled or disabled across all actions. This gem allows you to achieve that by tying random values to specific names and defining a scope. Within that scope, the same value will be consistently generated for each named variable.
11
+ For example, consider rolling out a new feature to a subset of requests. You may want to do this to allow testing a new feature by only enabling it for 10% of requests. You want to randomize which requests get the new feature, but ensure that within each request, the feature is consistently enabled or disabled across all actions. This gem allows you to achieve that by tying random values to specific names and defining a scope. Within that scope, the same value will be consistently generated for each named variable.
12
12
 
13
13
  ## Usage
14
14
 
@@ -89,7 +89,7 @@ config.middleware.use ConsistentRandom::RackMiddleware
89
89
 
90
90
  #### Sidekiq Middleware
91
91
 
92
- Add the middleware to your Sidekiq server configuration:
92
+ Add the middlewares to your Sidekiq configuration:
93
93
 
94
94
  ```ruby
95
95
  Sidekiq.configure_server do |config|
@@ -97,6 +97,54 @@ Sidekiq.configure_server do |config|
97
97
  chain.add ConsistentRandom::SidekiqMiddleware
98
98
  end
99
99
  end
100
+
101
+ Sidekiq.configure_client do |config|
102
+ config.client_middleware do |chain|
103
+ chain.add ConsistentRandom::SidekiqClientMiddleware
104
+ end
105
+ end
106
+ ```
107
+
108
+ Consistent random values will be propagated from the original request to any Sidekiq jobs so you will get consistent behavior on any ansynchronous jobs. You can disable this behavior on a job by setting the `conistent_random` sidekiq option to `false`:
109
+
110
+ ```ruby
111
+ class MyWorker
112
+ include Sidekiq::Job
113
+
114
+ sidekiq_options consistent_random: false
115
+
116
+ def perform
117
+ # Each job will use it's own random scope.
118
+ end
119
+ end
120
+ ```
121
+
122
+ ### ActiveJob
123
+
124
+ You can use consistent random values in your ActiveJob jobs by including the `ConsistentRandom::ActiveJob` module.
125
+
126
+ ```ruby
127
+ class MyJob < ApplicationJob
128
+ include ConsistentRandom::ActiveJob
129
+
130
+ def perform
131
+ # Job will use consistent random values using the same scope from when it was enqueued.
132
+ end
133
+ end
134
+ ```
135
+
136
+ Jobs will inherit the same consistent random values as the request that spawned the job. You can force a job to use it's own random scope by setting the `consistent_random` option to `false`:
137
+
138
+ ```ruby
139
+ class MyJob < ApplicationJob
140
+ include ConsistentRandom::ActiveJob
141
+
142
+ self.inherit_consistent_random_scope = false
143
+
144
+ def perform
145
+ # Job will use it's own random scope.
146
+ end
147
+ end
100
148
  ```
101
149
 
102
150
  ## Installation
data/VERSION CHANGED
@@ -1 +1 @@
1
- 1.0.0
1
+ 2.0.0
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ConsistentRandom
4
+ module ActiveJob
5
+ def self.included(base)
6
+ attr_reader :consistent_random_seeds
7
+
8
+ base.around_perform :peform_with_consistent_random_scope
9
+
10
+ base.class_attribute :inherit_consistent_random_scope, instance_writer: false
11
+ end
12
+
13
+ def serialize
14
+ job_data = super
15
+ if inherit_consistent_random_scope != false
16
+ seed = ConsistentRandom.current_seed
17
+ job_data["consistent_random_seed"] = seed unless seed.nil?
18
+ end
19
+ job_data
20
+ end
21
+
22
+ def deserialize(job_data)
23
+ super
24
+ @consistent_random_seeds = job_data["consistent_random_seed"]
25
+ end
26
+
27
+ private
28
+
29
+ def peform_with_consistent_random_scope(&block)
30
+ seeds = consistent_random_seeds unless inherit_consistent_random_scope == false
31
+ ConsistentRandom.scope(seeds, &block)
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ConsistentRandom
4
+ # Sidekiq client middleware that adds the current seeds to the job options. These
5
+ # seeds will be deserialized and used when the job is run on the server so that
6
+ # the client and server can share consistent random values.
7
+ class SidekiqClientMiddleware
8
+ if defined?(Sidekiq::ClientMiddleware)
9
+ include Sidekiq::ClientMiddleware
10
+ end
11
+
12
+ def call(job_class_or_string, job, queue, redis_pool)
13
+ unless job["consistent_random"] == false
14
+ seed = ConsistentRandom.current_seed
15
+ job["consistent_random_seed"] = seed unless seed.nil?
16
+ end
17
+ yield
18
+ end
19
+ end
20
+ end
@@ -9,7 +9,7 @@ class ConsistentRandom
9
9
  end
10
10
 
11
11
  def call(job_instance, job_payload, queue)
12
- ConsistentRandom.scope do
12
+ ConsistentRandom.scope(job_payload["consistent_random_seed"]) do
13
13
  yield
14
14
  end
15
15
  end
@@ -1,25 +1,56 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "consistent_random/context"
3
+ require "digest/sha1"
4
+ require "securerandom"
5
+
4
6
  require_relative "consistent_random/rack_middleware"
5
7
  require_relative "consistent_random/sidekiq_middleware"
8
+ require_relative "consistent_random/sidekiq_client_middleware"
9
+ require_relative "consistent_random/active_job"
6
10
 
7
11
  class ConsistentRandom
12
+ SEED_DIVISOR = (2**64 - 1).to_f
13
+ private_constant :SEED_DIVISOR
14
+
8
15
  class << self
9
16
  # Define a scope where consistent random values will be generated.
10
17
  #
18
+ # @param seeds [String, Symbol, Integer, Array<String>, Array<Integer>, nil] optional value to
19
+ # use for generating random numbers. By default a random value will be generated. If the
20
+ # scope is nested in another scope block, then the seed from the parent scope will be used
21
+ # by default.
11
22
  # @yield block of code to execute within the scope
12
23
  # @return the result of the block
13
- def scope
14
- existing_context = Thread.current[:consistent_random_context]
24
+ def scope(seed = nil)
25
+ existing_seed = Thread.current[:consistent_random_seed]
26
+ seed_value = case seed
27
+ when nil
28
+ existing_seed || SecureRandom.hex
29
+ when String, Symbol, Integer
30
+ seed.to_s
31
+ when Array
32
+ seed.map { |s| s.to_s }.join("\x1C")
33
+ else
34
+ raise ArgumentError, "Invalid seed value: #{seed.inspect}"
35
+ end
36
+
15
37
  begin
16
- context = Context.new(existing_context)
17
- Thread.current[:consistent_random_context] = context
38
+ Thread.current[:consistent_random_seed] = seed_value
18
39
  yield
19
40
  ensure
20
- Thread.current[:consistent_random_context] = existing_context
41
+ Thread.current[:consistent_random_seed] = existing_seed
21
42
  end
22
43
  end
44
+
45
+ # Get the current seed used to generate random numbers. This will return nil if called
46
+ # outside of a scope.
47
+ #
48
+ # @return [String, nil] the seed value for the current scope
49
+ # or nil if called outside of a scope.
50
+ # @api private
51
+ def current_seed
52
+ Thread.current[:consistent_random_seed]
53
+ end
23
54
  end
24
55
 
25
56
  # @param name [Object] a name used to identifuy a consistent random value
@@ -27,7 +58,9 @@ class ConsistentRandom
27
58
  @name = name
28
59
  end
29
60
 
30
- # Generate a random float. This method works the same as Kernel#rand.
61
+ # Generate a random number. The same number will be generated within a scope block.
62
+ # This method works the same as Kernel#rand. It will generate a consistent value even
63
+ # across Ruby versions and platforms.
31
64
  #
32
65
  # @param max [Integer, Range] the maximum value of the random float or a range indicating
33
66
  # the minimum and maximum values.
@@ -35,35 +68,73 @@ class ConsistentRandom
35
68
  # a number in that range. If max is an number, then it will be an integer between 0 and that
36
69
  # value. Otherwise, it will be a float between 0 and 1.
37
70
  def rand(max = nil)
38
- random.rand(max || 1.0)
71
+ value = seed / SEED_DIVISOR
72
+ case max
73
+ when nil
74
+ value
75
+ when Numeric
76
+ (value * max.to_i).to_i
77
+ when Range
78
+ cap_to_range(value, max)
79
+ end
39
80
  end
40
81
 
41
- # Generate a random integer. This method works the same as Random#bytes.
82
+ # Generate a random array of bytes. The same number will be generated within a scope block.
83
+ # This method works the same as Random#bytes.
42
84
  #
43
85
  # @param size [Integer] the number of bytes to generate.
44
86
  # @return [String] a string of random bytes.
45
87
  def bytes(size)
46
- random.bytes(size)
88
+ bytes = []
89
+ ((size + 19) / 20).times { |i| bytes << seed_hash("#{@name}#{i}").to_s }
90
+ bytes.join[0, size]
47
91
  end
48
92
 
49
- # Generate a random number generator for the given name. The generator will always
50
- # have the same seed within a scope.
93
+ # Generate a seed that can be used to generate random numbers. This seed will be
94
+ # return a consistent value when called within a scope.
51
95
  #
52
- # @return [Random] a random number generator
53
- def random
54
- Random.new(current_context.seed(@name))
96
+ # @return [Integer] a 64 bit integer for seeding random values
97
+ def seed
98
+ hash = seed_hash(@name)
99
+ hash.byteslice(0, 8).unpack1("Q>")
55
100
  end
56
101
 
57
102
  # @return [Boolean] true if the other object is a ConsistentRandom that returns
58
103
  # the same random number generator. If called outside of a scope, then it will
59
104
  # always return false.
60
105
  def ==(other)
61
- other.is_a?(self.class) && other.random = random
106
+ other.is_a?(self.class) && other.seed = seed
107
+ end
108
+
109
+ # Generate a random number generator for the given name. The generator will always
110
+ # have the same seed within a scope.
111
+ #
112
+ # This value is dependent on the Ruby Random class and may not generate consistent values
113
+ # across Ruby versions and platforms.
114
+ #
115
+ # @return [Random] a random number generator
116
+ def random
117
+ Random.new(seed)
62
118
  end
63
119
 
64
120
  private
65
121
 
66
- def current_context
67
- Thread.current[:consistent_random_context] || Context.new
122
+ def seed_hash(name)
123
+ random_seed = self.class.current_seed || SecureRandom.hex
124
+ Digest::SHA1.digest("#{random_seed}\x1C#{name}")
125
+ end
126
+
127
+ def cap_to_range(value, range)
128
+ min = range.begin
129
+ max = range.end
130
+ if min.nil? || max.nil?
131
+ raise ArgumentError, "Cannot generate random value for infinite range"
132
+ end
133
+
134
+ int_range = min.is_a?(Integer) && max.is_a?(Integer)
135
+ max += 1 if int_range && range.include?(max)
136
+
137
+ val = (value * (max - min)) + min
138
+ int_range ? val.to_i : val
68
139
  end
69
140
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: consistent_random
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Durand
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-10-06 00:00:00.000000000 Z
11
+ date: 2024-10-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -37,8 +37,9 @@ files:
37
37
  - VERSION
38
38
  - consistent_random.gemspec
39
39
  - lib/consistent_random.rb
40
- - lib/consistent_random/context.rb
40
+ - lib/consistent_random/active_job.rb
41
41
  - lib/consistent_random/rack_middleware.rb
42
+ - lib/consistent_random/sidekiq_client_middleware.rb
42
43
  - lib/consistent_random/sidekiq_middleware.rb
43
44
  homepage: https://github.com/bdurand/consistent_random
44
45
  licenses:
@@ -1,21 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- class ConsistentRandom
4
- class Context
5
- # @api private
6
- attr_reader :seeds
7
-
8
- # @param existing_context [Context, nil] Existing context to copy generators from
9
- def initialize(existing_context = nil)
10
- @seeds = (existing_context ? existing_context.seeds.dup : {})
11
- end
12
-
13
- # Return a random number generator for the given name and seed
14
- #
15
- # @param name [String] Name of the generator
16
- # @return [Random] Random number generator
17
- def seed(name)
18
- @seeds[name] ||= Random.new_seed
19
- end
20
- end
21
- end