consistent_random 2.2.0 → 2.3.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 +4 -4
- data/CHANGELOG.md +13 -0
- data/README.md +21 -2
- data/VERSION +1 -1
- data/consistent_random.gemspec +1 -1
- data/lib/consistent_random/active_job.rb +9 -7
- data/lib/consistent_random/testing.rb +3 -3
- data/lib/consistent_random.rb +19 -9
- metadata +4 -8
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1fd62d51e962f285e32463d2b1854f22a3d7fee49e7cb6f44cb3f09dc6af9654
|
|
4
|
+
data.tar.gz: f2dd72200a420d2b36dbae1336f477cafe971993772b0d5afac5a269856a3a7e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 56111c5d7231bf39fadca5ef39f1bef04b23df47962646774355f18c9a543dfec3f74eb5e3ad0a32acd0db66d684f8852eb2161cca4c4d5bb5a10fe729726103
|
|
7
|
+
data.tar.gz: 8daa15435772ad90dcb34c1797edaece7dcf78c34f088a532b843fa9c371f062fb9cd969f99d8afe40702f11c8bcbd6f439d6efc71ab60a1b50aa72f92cfcf97
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,19 @@ 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.3.0
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- `ConsistentRandom#rand` can no longer return an out of range value. Previously there was a very small chance (about 1 in 2⁵³) that a seed could produce a value of exactly 1.0, causing `rand` to return 1.0, `rand(n)` to return `n`, and `rand(range)` to return a value above the range. Random floats are now derived from the top 53 bits of the seed, guaranteeing a uniform value in `[0, 1)`. **Note:** this changes the exact values generated for existing seeds (the distribution is unchanged), so percentage rollouts in flight during an upgrade may reassign.
|
|
12
|
+
- Fixed distribution bias in `ConsistentRandom#rand` for ranges with negative bounds. Values were truncated toward zero instead of floored, so for a range like `-5..5` the minimum value was never returned and zero was returned twice as often as other values.
|
|
13
|
+
- `ConsistentRandom#rand` now raises an `ArgumentError` for empty ranges (e.g. `5..1`) and non-numeric ranges instead of returning out of range values or raising a `NoMethodError`.
|
|
14
|
+
- `ConsistentRandom::Testing#bytes` now raises an `ArgumentError` for empty strings, which were previously accepted but silently ignored, producing non-deterministic values in tests.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- `ConsistentRandom.current_seed` is now public API; it can be used to propagate a scope into threads or fibers, which do not inherit the fiber-local scope state.
|
|
19
|
+
|
|
7
20
|
## 2.2.0
|
|
8
21
|
|
|
9
22
|
### Added
|
data/README.md
CHANGED
|
@@ -14,6 +14,7 @@ For example, consider rolling out a new feature to a subset of requests. You may
|
|
|
14
14
|
|
|
15
15
|
## Table of Contents
|
|
16
16
|
- [Usage](#usage)
|
|
17
|
+
- [Threads and Fibers](#threads-and-fibers)
|
|
17
18
|
- [Middlewares](#middlewares)
|
|
18
19
|
- [Rack Middleware](#rack-middleware)
|
|
19
20
|
- [Sidekiq Middleware](#sidekiq-middleware)
|
|
@@ -78,6 +79,24 @@ random = ConsistentRandom.new("foobar")
|
|
|
78
79
|
random.rand != random.rand # => true
|
|
79
80
|
```
|
|
80
81
|
|
|
82
|
+
### Threads and Fibers
|
|
83
|
+
|
|
84
|
+
Scopes are stored in fiber-local state, so they do not propagate to threads or fibers spawned inside the scope block. Code running in a `Thread.new` block, an `Enumerator` (which runs on its own fiber), or Rails' `load_async` will not see the surrounding scope and will generate independent random values.
|
|
85
|
+
|
|
86
|
+
If you need consistent values in spawned threads or fibers, capture the current seed and re-establish the scope inside them:
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
ConsistentRandom.scope do
|
|
90
|
+
seed = ConsistentRandom.current_seed
|
|
91
|
+
|
|
92
|
+
Thread.new do
|
|
93
|
+
ConsistentRandom.scope(seed) do
|
|
94
|
+
# Values here are consistent with the parent scope.
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
```
|
|
99
|
+
|
|
81
100
|
## Middlewares
|
|
82
101
|
|
|
83
102
|
The gem provides built-in middlewares for Rack, Sidekiq, and ActiveJob. These middlewares allow you to automatically scope web requests and propagate consistent random values from the original request to asynchronous jobs.
|
|
@@ -139,7 +158,7 @@ Sidekiq.configure_client do |config|
|
|
|
139
158
|
end
|
|
140
159
|
```
|
|
141
160
|
|
|
142
|
-
Consistent random values will be propagated from the original request to any Sidekiq jobs so you will get consistent behavior on any
|
|
161
|
+
Consistent random values will be propagated from the original request to any Sidekiq jobs so you will get consistent behavior on any asynchronous jobs. You can disable this behavior on a job by setting the `consistent_random` sidekiq option to `false`:
|
|
143
162
|
|
|
144
163
|
```ruby
|
|
145
164
|
class MyWorker
|
|
@@ -224,7 +243,7 @@ end
|
|
|
224
243
|
|
|
225
244
|
# You can also specify values for specific names.
|
|
226
245
|
# If a values isn't specified, it will return a random value.
|
|
227
|
-
ConsistentRandom.testing(foo: 0.5, bar: 0.8) do
|
|
246
|
+
ConsistentRandom.testing.rand(foo: 0.5, bar: 0.8) do
|
|
228
247
|
expect(ConsistentRandom.new("foo").rand).to eq(0.5)
|
|
229
248
|
expect(ConsistentRandom.new("bar").rand).to eq(0.8)
|
|
230
249
|
end
|
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2.
|
|
1
|
+
2.3.0
|
data/consistent_random.gemspec
CHANGED
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
class ConsistentRandom
|
|
4
4
|
module ActiveJob
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
# @return [String, nil] the seed deserialized from the job data
|
|
6
|
+
# @api private
|
|
7
|
+
attr_reader :consistent_random_seed
|
|
7
8
|
|
|
8
|
-
|
|
9
|
+
def self.included(base)
|
|
10
|
+
base.around_perform :perform_with_consistent_random_scope
|
|
9
11
|
|
|
10
12
|
base.class_attribute :inherit_consistent_random_scope, instance_writer: false
|
|
11
13
|
end
|
|
@@ -21,14 +23,14 @@ class ConsistentRandom
|
|
|
21
23
|
|
|
22
24
|
def deserialize(job_data)
|
|
23
25
|
super
|
|
24
|
-
@
|
|
26
|
+
@consistent_random_seed = job_data["consistent_random_seed"]
|
|
25
27
|
end
|
|
26
28
|
|
|
27
29
|
private
|
|
28
30
|
|
|
29
|
-
def
|
|
30
|
-
|
|
31
|
-
ConsistentRandom.scope(
|
|
31
|
+
def perform_with_consistent_random_scope(&block)
|
|
32
|
+
seed = consistent_random_seed unless inherit_consistent_random_scope == false
|
|
33
|
+
ConsistentRandom.scope(seed, &block)
|
|
32
34
|
end
|
|
33
35
|
end
|
|
34
36
|
end
|
|
@@ -65,7 +65,7 @@ class ConsistentRandom
|
|
|
65
65
|
def bytes(options, &block)
|
|
66
66
|
options = validate_bytes(options)
|
|
67
67
|
unless options
|
|
68
|
-
raise ArgumentError.new("Argument must be a String or a Hash with String values")
|
|
68
|
+
raise ArgumentError.new("Argument must be a non-empty String or a Hash with non-empty String values")
|
|
69
69
|
end
|
|
70
70
|
|
|
71
71
|
@bytes_hash = options.default ? options.merge(@bytes_hash) : @bytes_hash.merge(options)
|
|
@@ -151,9 +151,9 @@ class ConsistentRandom
|
|
|
151
151
|
end
|
|
152
152
|
|
|
153
153
|
def validate_bytes(options)
|
|
154
|
-
if options.is_a?(Hash) && options.values.all? { |value| value.is_a?(String) }
|
|
154
|
+
if options.is_a?(Hash) && options.values.all? { |value| value.is_a?(String) && !value.empty? }
|
|
155
155
|
options.each_with_object({}) { |(key, value), hash| hash[key.to_s] = value.encode(Encoding::ASCII_8BIT) }
|
|
156
|
-
elsif options.is_a?(String)
|
|
156
|
+
elsif options.is_a?(String) && !options.empty?
|
|
157
157
|
Hash.new(options)
|
|
158
158
|
end
|
|
159
159
|
end
|
data/lib/consistent_random.rb
CHANGED
|
@@ -4,8 +4,11 @@ require "digest/sha1"
|
|
|
4
4
|
require "securerandom"
|
|
5
5
|
|
|
6
6
|
class ConsistentRandom
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
# Divisor for converting the top 53 bits of a seed into a Float. Using 53 bits
|
|
8
|
+
# ensures the result is exactly representable so values are uniform in [0, 1)
|
|
9
|
+
# and can never round up to 1.0.
|
|
10
|
+
FLOAT_DIVISOR = 2.0**53
|
|
11
|
+
private_constant :FLOAT_DIVISOR
|
|
9
12
|
|
|
10
13
|
autoload :RackMiddleware, "consistent_random/rack_middleware"
|
|
11
14
|
autoload :SidekiqMiddleware, "consistent_random/sidekiq_middleware"
|
|
@@ -15,7 +18,7 @@ class ConsistentRandom
|
|
|
15
18
|
class << self
|
|
16
19
|
# Define a scope where consistent random values will be generated.
|
|
17
20
|
#
|
|
18
|
-
# @param
|
|
21
|
+
# @param seed [String, Symbol, Integer, Array<String>, Array<Integer>, nil] optional value to
|
|
19
22
|
# use for generating random numbers. By default a random value will be generated. If the
|
|
20
23
|
# scope is nested in another scope block, then the seed from the parent scope will be used
|
|
21
24
|
# by default.
|
|
@@ -29,7 +32,7 @@ class ConsistentRandom
|
|
|
29
32
|
when String, Symbol, Integer
|
|
30
33
|
seed.to_s
|
|
31
34
|
when Array
|
|
32
|
-
seed.
|
|
35
|
+
seed.join("\x1C")
|
|
33
36
|
else
|
|
34
37
|
raise ArgumentError, "Invalid seed value: #{seed.inspect}"
|
|
35
38
|
end
|
|
@@ -43,11 +46,11 @@ class ConsistentRandom
|
|
|
43
46
|
end
|
|
44
47
|
|
|
45
48
|
# Get the current seed used to generate random numbers. This will return nil if called
|
|
46
|
-
# outside of a scope.
|
|
49
|
+
# outside of a scope. The value can be passed to a new scope to generate the same
|
|
50
|
+
# random values in another thread or fiber.
|
|
47
51
|
#
|
|
48
52
|
# @return [String, nil] the seed value for the current scope
|
|
49
53
|
# or nil if called outside of a scope.
|
|
50
|
-
# @api private
|
|
51
54
|
def current_seed
|
|
52
55
|
Thread.current[:consistent_random_seed]
|
|
53
56
|
end
|
|
@@ -74,7 +77,7 @@ class ConsistentRandom
|
|
|
74
77
|
# a number in that range. If max is an number, then it will be an integer between 0 and that
|
|
75
78
|
# value. Otherwise, it will be a float between 0 and 1.
|
|
76
79
|
def rand(max = nil)
|
|
77
|
-
value = Testing.current&.rand_for(name) || seed /
|
|
80
|
+
value = Testing.current&.rand_for(name) || (seed >> 11) / FLOAT_DIVISOR
|
|
78
81
|
case max
|
|
79
82
|
when nil
|
|
80
83
|
value
|
|
@@ -92,7 +95,6 @@ class ConsistentRandom
|
|
|
92
95
|
# @return [String] a string of random bytes.
|
|
93
96
|
def bytes(size)
|
|
94
97
|
test_bytes = Testing.current&.bytes_for(name)
|
|
95
|
-
test_bytes = nil if test_bytes&.empty?
|
|
96
98
|
chunk_size = (test_bytes ? test_bytes.length : 20)
|
|
97
99
|
|
|
98
100
|
bytes = []
|
|
@@ -147,11 +149,19 @@ class ConsistentRandom
|
|
|
147
149
|
raise ArgumentError, "Cannot generate random value for infinite range"
|
|
148
150
|
end
|
|
149
151
|
|
|
152
|
+
unless min.is_a?(Numeric) && max.is_a?(Numeric)
|
|
153
|
+
raise ArgumentError, "Cannot generate random value for non-numeric range"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
if min > max || (range.exclude_end? && min == max)
|
|
157
|
+
raise ArgumentError, "Cannot generate random value for empty range"
|
|
158
|
+
end
|
|
159
|
+
|
|
150
160
|
int_range = min.is_a?(Integer) && max.is_a?(Integer)
|
|
151
161
|
max += 1 if int_range && range.include?(max)
|
|
152
162
|
|
|
153
163
|
val = (value * (max - min)) + min
|
|
154
|
-
int_range ? val.
|
|
164
|
+
int_range ? val.floor : val
|
|
155
165
|
end
|
|
156
166
|
end
|
|
157
167
|
|
metadata
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: consistent_random
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.
|
|
4
|
+
version: 2.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Brian Durand
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: bin
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: bundler
|
|
@@ -24,7 +23,6 @@ dependencies:
|
|
|
24
23
|
- - ">="
|
|
25
24
|
- !ruby/object:Gem::Version
|
|
26
25
|
version: '0'
|
|
27
|
-
description:
|
|
28
26
|
email:
|
|
29
27
|
- bbdurand@gmail.com
|
|
30
28
|
executables: []
|
|
@@ -49,7 +47,6 @@ metadata:
|
|
|
49
47
|
homepage_uri: https://github.com/bdurand/consistent_random
|
|
50
48
|
source_code_uri: https://github.com/bdurand/consistent_random
|
|
51
49
|
changelog_uri: https://github.com/bdurand/consistent_random/blob/main/CHANGELOG.md
|
|
52
|
-
post_install_message:
|
|
53
50
|
rdoc_options: []
|
|
54
51
|
require_paths:
|
|
55
52
|
- lib
|
|
@@ -57,15 +54,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
57
54
|
requirements:
|
|
58
55
|
- - ">="
|
|
59
56
|
- !ruby/object:Gem::Version
|
|
60
|
-
version: '2.
|
|
57
|
+
version: '2.6'
|
|
61
58
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
62
59
|
requirements:
|
|
63
60
|
- - ">="
|
|
64
61
|
- !ruby/object:Gem::Version
|
|
65
62
|
version: '0'
|
|
66
63
|
requirements: []
|
|
67
|
-
rubygems_version:
|
|
68
|
-
signing_key:
|
|
64
|
+
rubygems_version: 4.0.3
|
|
69
65
|
specification_version: 4
|
|
70
66
|
summary: Generates consistent random values within a defined scope, ensuring deterministic
|
|
71
67
|
behavior for use in feature rollouts and other scoped operations.
|