throttle_machines 0.2.4-x86_64-linux → 0.3.1-x86_64-linux

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: b903b7c1264b244a780a3730bd09d7bdab11db1f28961910980d93d7722ff92e
4
- data.tar.gz: d6229cb0f861acea1f76e99a8ec4bcd0c3217f10745467f6411c5383519fe84b
3
+ metadata.gz: c0e7b82b0a0e6aee13b28e5dff8d398583123ae16859094db1f5731563b6756a
4
+ data.tar.gz: 55eae369f9ff8d44e771ce1d30af98e81468c4e4590cb283121236dad134d726
5
5
  SHA512:
6
- metadata.gz: f35c2f3f16752f64e34105a201f5854b157a0f5092972acb47310802400502474d3d74ca52852056b77932c442af3d76ab0edec0becdd0142a07d4c17a11159e
7
- data.tar.gz: 7c20314309fda5045cc7109b03b79dcdac901cb1ce9e83011cf59f6f1906c24f3fd9e6db7b86aff3091deebd21fdbd02118db08d9bce43d7a3f5be6c06111d0c
6
+ metadata.gz: 9e2199d9db95a4b57fc42ed9d702a464e7efccc6a43312ef0e46e33b16cba5ec19bf6a1b684a8eaab2463d0ae89a4fcae063571a198a48b2d53a526f7a5730c5
7
+ data.tar.gz: b10d373aa0a749dddea669a8f50d1118c3101fb68b84a1411eed792457edae55e0f8cb0ecf43a389da389bc5884a9cead734df1a4a89687eb1027765ddc2bd56
@@ -57,8 +57,28 @@ module ThrottleMachines
57
57
  nil
58
58
  end
59
59
 
60
+ # Rounding slack for time comparisons, in units of the clock's precision.
61
+ # Matches the native core.
62
+ SLACK_ULPS = 64
63
+
60
64
  protected
61
65
 
66
+ # Times are running sums and differences of float seconds, so an
67
+ # on-schedule arrival can miss an exact boundary by a few ulps. The slack
68
+ # scales with the clock's magnitude.
69
+ def rounding_slack(time)
70
+ Float::EPSILON * SLACK_ULPS * [time.abs, 1.0].max
71
+ end
72
+
73
+ def gcra_allowed?(tat, now, delay_tolerance)
74
+ tat - now <= delay_tolerance + rounding_slack(tat)
75
+ end
76
+
77
+ # Whether a token is available, allowing for rounding in the refill.
78
+ def token_available?(tokens, now, refill_rate)
79
+ tokens >= 1 - (rounding_slack(now) * refill_rate)
80
+ end
81
+
62
82
  def current_time
63
83
  # Use monotonic time for consistency with BreakerMachines
64
84
  ThrottleMachines.monotonic_time
@@ -86,7 +86,7 @@ module ThrottleMachines
86
86
  state = @gcra_states[key] || { tat: 0.0 }
87
87
 
88
88
  tat = [state[:tat], now].max
89
- allow = tat - now <= delay_tolerance
89
+ allow = gcra_allowed?(tat, now, delay_tolerance)
90
90
 
91
91
  if allow
92
92
  new_tat = tat + emission_interval
@@ -107,7 +107,7 @@ module ThrottleMachines
107
107
  state = @gcra_states[key] || { tat: 0.0 }
108
108
 
109
109
  tat = [state[:tat], now].max
110
- allow = tat - now <= delay_tolerance
110
+ allow = gcra_allowed?(tat, now, delay_tolerance)
111
111
 
112
112
  {
113
113
  allowed: allow,
@@ -130,8 +130,8 @@ module ThrottleMachines
130
130
  bucket[:last_refill] = now
131
131
 
132
132
  # Check if we can consume a token
133
- if bucket[:tokens] >= 1
134
- bucket[:tokens] -= 1
133
+ if token_available?(bucket[:tokens], now, refill_rate)
134
+ bucket[:tokens] = [bucket[:tokens] - 1, 0.0].max
135
135
  @token_buckets[key] = bucket.merge(expires_at: now + ttl)
136
136
 
137
137
  {
@@ -161,11 +161,11 @@ module ThrottleMachines
161
161
  tokens_to_add = elapsed * refill_rate
162
162
  current_tokens = [bucket[:tokens] + tokens_to_add, capacity].min
163
163
 
164
- if current_tokens >= 1
164
+ if token_available?(current_tokens, now, refill_rate)
165
165
  {
166
166
  allowed: true,
167
167
  retry_after: 0,
168
- tokens_remaining: (current_tokens - 1).floor
168
+ tokens_remaining: [current_tokens - 1, 0].max.floor
169
169
  }
170
170
  else
171
171
  retry_after = (1 - current_tokens) / refill_rate
@@ -12,11 +12,15 @@ else
12
12
  end
13
13
 
14
14
  tat = math.max(tat, now)
15
- local allow = (tat - now) <= delay_tolerance
15
+ -- The TAT is a running sum of emission intervals, so an on-schedule arrival
16
+ -- can overshoot the tolerance by a few ulps; the slack scales with the clock.
17
+ local slack = 2.220446049250313e-16 * 64 * math.max(math.abs(tat), 1)
18
+ local allow = (tat - now) <= delay_tolerance + slack
16
19
 
17
20
  if allow then
18
21
  local new_tat = tat + emission_interval
19
22
  redis.call('SET', key, new_tat, 'EX', ttl)
20
23
  end
21
24
 
22
- return { allow and 1 or 0, tat }
25
+ -- Redis truncates Lua numbers in replies to integers; send the TAT as text.
26
+ return { allow and 1 or 0, string.format('%.17g', tat) }
@@ -11,6 +11,10 @@ else
11
11
  end
12
12
 
13
13
  tat = math.max(tat, now)
14
- local allow = (tat - now) <= delay_tolerance
14
+ -- The TAT is a running sum of emission intervals, so an on-schedule arrival
15
+ -- can overshoot the tolerance by a few ulps; the slack scales with the clock.
16
+ local slack = 2.220446049250313e-16 * 64 * math.max(math.abs(tat), 1)
17
+ local allow = (tat - now) <= delay_tolerance + slack
15
18
 
16
- return { allow and 1 or 0, tat }
19
+ -- Redis truncates Lua numbers in replies to integers; send the TAT as text.
20
+ return { allow and 1 or 0, string.format('%.17g', tat) }
@@ -12,7 +12,13 @@ local elapsed = now - last_refill
12
12
  local tokens_to_add = elapsed * refill_rate
13
13
  tokens = math.min(tokens + tokens_to_add, capacity)
14
14
 
15
- local allow = tokens >= 1
16
- local tokens_after = allow and (tokens - 1) or 0
15
+ -- `elapsed` carries the clock's rounding; a token due exactly now may come
16
+ -- out a hair short of 1. The slack scales with the clock, as in the native core.
17
+ local slack = 2.220446049250313e-16 * 64 * math.max(math.abs(now), 1) * refill_rate
18
+ local allow = tokens >= 1 - slack
19
+ -- Admitted: tokens left after this request. Refused: the fractional balance,
20
+ -- which retry_after is computed from.
21
+ local tokens_after = allow and math.max(tokens - 1, 0) or tokens
17
22
 
18
- return { allow and 1 or 0, tokens_after }
23
+ -- Redis truncates Lua numbers in replies to integers; send the count as text.
24
+ return { allow and 1 or 0, string.format('%.17g', tokens_after) }
@@ -13,11 +13,15 @@ local elapsed = now - last_refill
13
13
  local tokens_to_add = elapsed * refill_rate
14
14
  tokens = math.min(tokens + tokens_to_add, capacity)
15
15
 
16
- local allow = tokens >= 1
16
+ -- `elapsed` carries the clock's rounding; a token due exactly now may come
17
+ -- out a hair short of 1. The slack scales with the clock, as in the native core.
18
+ local slack = 2.220446049250313e-16 * 64 * math.max(math.abs(now), 1) * refill_rate
19
+ local allow = tokens >= 1 - slack
17
20
  if allow then
18
- tokens = tokens - 1
21
+ tokens = math.max(tokens - 1, 0)
19
22
  redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
20
23
  redis.call('EXPIRE', key, ttl)
21
24
  end
22
25
 
23
- return { allow and 1 or 0, tokens }
26
+ -- Redis truncates Lua numbers in replies to integers; send the count as text.
27
+ return { allow and 1 or 0, string.format('%.17g', tokens) }
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'digest/sha1'
4
+
3
5
  module ThrottleMachines
4
6
  module Storage
5
7
  class Redis < Base
@@ -13,17 +15,18 @@ module ThrottleMachines
13
15
  INCREMENT_COUNTER_SCRIPT = File.read(File.join(LUA_SCRIPTS_DIR, 'increment_counter.lua'))
14
16
  # Breaker scripts removed: breaker state is owned by BreakerMachines
15
17
 
18
+ # Redis caches scripts by the SHA1 of their source, so the SHAs are known
19
+ # without a SCRIPT LOAD round trip.
20
+ GCRA_SHA = Digest::SHA1.hexdigest(GCRA_SCRIPT)
21
+ TOKEN_BUCKET_SHA = Digest::SHA1.hexdigest(TOKEN_BUCKET_SCRIPT)
22
+ PEEK_GCRA_SHA = Digest::SHA1.hexdigest(PEEK_GCRA_SCRIPT)
23
+ PEEK_TOKEN_BUCKET_SHA = Digest::SHA1.hexdigest(PEEK_TOKEN_BUCKET_SCRIPT)
24
+
16
25
  def initialize(options = {})
17
26
  super
18
27
  @redis = options[:redis] || options[:client] || options[:pool]
19
28
  @prefix = options[:prefix] || 'throttle:'
20
29
 
21
- # Cache scripts to avoid repeated script loads
22
- @gcra_sha = nil
23
- @token_bucket_sha = nil
24
- @peek_gcra_sha = nil
25
- @peek_token_bucket_sha = nil
26
-
27
30
  # Validate Redis connection
28
31
  validate_redis_connection!
29
32
  end
@@ -56,111 +59,53 @@ module ThrottleMachines
56
59
 
57
60
  # GCRA operations (atomic via Lua)
58
61
  def check_gcra_limit(key, emission_interval, delay_tolerance, ttl)
59
- ensure_gcra_script_loaded!
60
-
61
- result = with_redis do |redis|
62
- redis.evalsha(
63
- @gcra_sha,
64
- keys: [prefixed(key)],
65
- argv: [emission_interval, delay_tolerance, ttl, current_time]
66
- )
67
- end
62
+ now = current_time
63
+ result = run_script(GCRA_SCRIPT, GCRA_SHA,
64
+ keys: [prefixed(key)],
65
+ argv: [emission_interval, delay_tolerance, ttl, now])
68
66
 
69
67
  allowed = result[0] == 1
70
- tat = result[1]
71
- now = current_time
68
+ tat = Float(result[1])
72
69
 
73
70
  {
74
71
  allowed: allowed,
75
72
  retry_after: allowed ? 0 : (tat - now - delay_tolerance),
76
73
  tat: tat
77
74
  }
78
- rescue ::Redis::CommandError => e
79
- raise unless e.message.include?('NOSCRIPT')
80
-
81
- @gcra_sha = nil
82
- retry
83
75
  end
84
76
 
85
77
  # Token bucket operations (atomic via Lua)
86
78
  def check_token_bucket(key, capacity, refill_rate, ttl)
87
- ensure_token_bucket_script_loaded!
88
-
89
- result = with_redis do |redis|
90
- redis.evalsha(
91
- @token_bucket_sha,
92
- keys: [prefixed(key)],
93
- argv: [capacity, refill_rate, ttl, current_time]
94
- )
95
- end
96
-
97
- allowed = result[0] == 1
98
- tokens = result[1]
79
+ result = run_script(TOKEN_BUCKET_SCRIPT, TOKEN_BUCKET_SHA,
80
+ keys: [prefixed(key)],
81
+ argv: [capacity, refill_rate, ttl, current_time])
99
82
 
100
- {
101
- allowed: allowed,
102
- retry_after: allowed ? 0 : (1 - tokens) / refill_rate,
103
- tokens_remaining: tokens.floor
104
- }
105
- rescue ::Redis::CommandError => e
106
- raise unless e.message.include?('NOSCRIPT')
107
-
108
- @token_bucket_sha = nil
109
- retry
83
+ token_bucket_result(result, refill_rate)
110
84
  end
111
85
 
112
86
  # Peek methods for non-consuming checks
113
87
  def peek_gcra_limit(key, emission_interval, delay_tolerance)
114
- ensure_peek_gcra_script_loaded!
115
-
116
- result = with_redis do |redis|
117
- redis.evalsha(
118
- @peek_gcra_sha,
119
- keys: [prefixed(key)],
120
- argv: [emission_interval, delay_tolerance, current_time]
121
- )
122
- end
88
+ now = current_time
89
+ result = run_script(PEEK_GCRA_SCRIPT, PEEK_GCRA_SHA,
90
+ keys: [prefixed(key)],
91
+ argv: [emission_interval, delay_tolerance, now])
123
92
 
124
93
  allowed = result[0] == 1
125
- tat = result[1]
126
- now = current_time
94
+ tat = Float(result[1])
127
95
 
128
96
  {
129
97
  allowed: allowed,
130
98
  retry_after: allowed ? 0 : (tat - now - delay_tolerance),
131
99
  tat: tat
132
100
  }
133
- rescue ::Redis::CommandError => e
134
- raise unless e.message.include?('NOSCRIPT')
135
-
136
- @peek_gcra_sha = nil
137
- retry
138
101
  end
139
102
 
140
103
  def peek_token_bucket(key, capacity, refill_rate)
141
- ensure_peek_token_bucket_script_loaded!
142
-
143
- result = with_redis do |redis|
144
- redis.evalsha(
145
- @peek_token_bucket_sha,
146
- keys: [prefixed(key)],
147
- argv: [capacity, refill_rate, current_time]
148
- )
149
- end
104
+ result = run_script(PEEK_TOKEN_BUCKET_SCRIPT, PEEK_TOKEN_BUCKET_SHA,
105
+ keys: [prefixed(key)],
106
+ argv: [capacity, refill_rate, current_time])
150
107
 
151
- allowed = result[0] == 1
152
- tokens_remaining = result[1]
153
-
154
- {
155
- allowed: allowed,
156
- retry_after: allowed ? 0 : (1 - tokens_remaining) / refill_rate,
157
- tokens_remaining: tokens_remaining.floor
158
- }
159
- rescue ::Redis::CommandError => e
160
- raise unless e.message.include?('NOSCRIPT')
161
-
162
- @peek_token_bucket_sha = nil
163
- retry
108
+ token_bucket_result(result, refill_rate)
164
109
  end
165
110
 
166
111
  # No circuit breaker operations: breaker state is owned by BreakerMachines
@@ -188,24 +133,34 @@ module ThrottleMachines
188
133
 
189
134
  private
190
135
 
191
- def prefixed(key)
192
- "#{@prefix}#{key}"
193
- end
136
+ # `result` is [allowed, tokens]: the balance after an admission, or the
137
+ # fractional balance a refusal waits on. Matches Storage::Memory.
138
+ def token_bucket_result(result, refill_rate)
139
+ allowed = result[0] == 1
140
+ tokens = Float(result[1])
194
141
 
195
- def ensure_gcra_script_loaded!
196
- @ensure_gcra_script_loaded ||= with_redis { |r| r.script(:load, GCRA_SCRIPT) }
142
+ {
143
+ allowed: allowed,
144
+ retry_after: allowed ? 0 : (1 - tokens) / refill_rate,
145
+ tokens_remaining: allowed ? tokens.floor : 0
146
+ }
197
147
  end
198
148
 
199
- def ensure_token_bucket_script_loaded!
200
- @ensure_token_bucket_script_loaded ||= with_redis { |r| r.script(:load, TOKEN_BUCKET_SCRIPT) }
149
+ def prefixed(key)
150
+ "#{@prefix}#{key}"
201
151
  end
202
152
 
203
- def ensure_peek_gcra_script_loaded!
204
- @ensure_peek_gcra_script_loaded ||= with_redis { |r| r.script(:load, PEEK_GCRA_SCRIPT) }
205
- end
153
+ # EVALSHA by the precomputed SHA. A server without the script cached (a
154
+ # restart, SCRIPT FLUSH, failover) answers NOSCRIPT; EVAL then runs the
155
+ # source once and caches it for later calls. Any other error propagates.
156
+ def run_script(script, sha, keys:, argv:)
157
+ with_redis do |redis|
158
+ redis.evalsha(sha, keys: keys, argv: argv)
159
+ rescue ::Redis::CommandError => e
160
+ raise unless e.message.include?('NOSCRIPT')
206
161
 
207
- def ensure_peek_token_bucket_script_loaded!
208
- @ensure_peek_token_bucket_script_loaded ||= with_redis { |r| r.script(:load, PEEK_TOKEN_BUCKET_SCRIPT) }
162
+ redis.eval(script, keys: keys, argv: argv)
163
+ end
209
164
  end
210
165
 
211
166
  def validate_redis_connection!
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ThrottleMachines
4
- VERSION = '0.2.4'
4
+ VERSION = '0.3.1'
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: throttle_machines
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.4
4
+ version: 0.3.1
5
5
  platform: x86_64-linux
6
6
  authors:
7
7
  - Abdelkader Boudih
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-10 00:00:00.000000000 Z
11
+ date: 2026-10-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -16,14 +16,14 @@ dependencies:
16
16
  requirements:
17
17
  - - ">="
18
18
  - !ruby/object:Gem::Version
19
- version: 8.0.4
19
+ version: '8.1'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - ">="
25
25
  - !ruby/object:Gem::Version
26
- version: 8.0.4
26
+ version: '8.1'
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: concurrent-ruby
29
29
  requirement: !ruby/object:Gem::Requirement
@@ -72,42 +72,42 @@ dependencies:
72
72
  requirements:
73
73
  - - "~>"
74
74
  - !ruby/object:Gem::Version
75
- version: '0.19'
75
+ version: '0.20'
76
76
  type: :runtime
77
77
  prerelease: false
78
78
  version_requirements: !ruby/object:Gem::Requirement
79
79
  requirements:
80
80
  - - "~>"
81
81
  - !ruby/object:Gem::Version
82
- version: '0.19'
82
+ version: '0.20'
83
83
  - !ruby/object:Gem::Dependency
84
84
  name: chrono_machines
85
85
  requirement: !ruby/object:Gem::Requirement
86
86
  requirements:
87
87
  - - "~>"
88
88
  - !ruby/object:Gem::Version
89
- version: '0.9'
89
+ version: '0.10'
90
90
  type: :runtime
91
91
  prerelease: false
92
92
  version_requirements: !ruby/object:Gem::Requirement
93
93
  requirements:
94
94
  - - "~>"
95
95
  - !ruby/object:Gem::Version
96
- version: '0.9'
96
+ version: '0.10'
97
97
  - !ruby/object:Gem::Dependency
98
98
  name: minitest
99
99
  requirement: !ruby/object:Gem::Requirement
100
100
  requirements:
101
101
  - - "~>"
102
102
  - !ruby/object:Gem::Version
103
- version: '5.16'
103
+ version: '6.0'
104
104
  type: :development
105
105
  prerelease: false
106
106
  version_requirements: !ruby/object:Gem::Requirement
107
107
  requirements:
108
108
  - - "~>"
109
109
  - !ruby/object:Gem::Version
110
- version: '5.16'
110
+ version: '6.0'
111
111
  - !ruby/object:Gem::Dependency
112
112
  name: rake
113
113
  requirement: !ruby/object:Gem::Requirement