async-limiter 1.5.4 → 2.0.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
- checksums.yaml.gz.sig +0 -0
- data/context/generic-limiter.md +167 -0
- data/context/getting-started.md +226 -0
- data/context/index.yaml +41 -0
- data/context/limited-limiter.md +184 -0
- data/context/queued-limiter.md +109 -0
- data/context/timing-strategies.md +666 -0
- data/context/token-usage.md +85 -0
- data/lib/async/limiter/generic.rb +160 -0
- data/lib/async/limiter/limited.rb +103 -0
- data/lib/async/limiter/queued.rb +85 -0
- data/lib/async/limiter/timing/burst.rb +153 -0
- data/lib/async/limiter/timing/fixed_window.rb +42 -0
- data/lib/async/limiter/timing/leaky_bucket.rb +146 -0
- data/lib/async/limiter/timing/none.rb +56 -0
- data/lib/async/limiter/timing/ordered.rb +58 -0
- data/lib/async/limiter/timing/sliding_window.rb +152 -0
- data/lib/async/limiter/token.rb +102 -0
- data/lib/async/limiter/version.rb +10 -3
- data/lib/async/limiter.rb +21 -7
- data/lib/metrics/provider/async/limiter/generic.rb +74 -0
- data/lib/metrics/provider/async/limiter.rb +7 -0
- data/lib/traces/provider/async/limiter/generic.rb +41 -0
- data/lib/traces/provider/async/limiter.rb +7 -0
- data/license.md +25 -0
- data/readme.md +45 -0
- data/releases.md +50 -0
- data.tar.gz.sig +0 -0
- metadata +68 -83
- metadata.gz.sig +0 -0
- data/lib/async/limiter/concurrent.rb +0 -101
- data/lib/async/limiter/constants.rb +0 -6
- data/lib/async/limiter/unlimited.rb +0 -53
- data/lib/async/limiter/window/continuous.rb +0 -21
- data/lib/async/limiter/window/fixed.rb +0 -21
- data/lib/async/limiter/window/sliding.rb +0 -21
- data/lib/async/limiter/window.rb +0 -296
@@ -0,0 +1,146 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Released under the MIT License.
|
4
|
+
# Copyright, 2025, by Shopify Inc.
|
5
|
+
# Copyright, 2025, by Samuel Williams.
|
6
|
+
|
7
|
+
require "async/clock"
|
8
|
+
|
9
|
+
module Async
|
10
|
+
module Limiter
|
11
|
+
module Timing
|
12
|
+
# Leaky bucket timing strategy that smooths traffic flow.
|
13
|
+
#
|
14
|
+
# This strategy maintains a "bucket" that gradually "leaks" capacity over time,
|
15
|
+
# enforcing a steady output rate regardless of input bursts.
|
16
|
+
class LeakyBucket
|
17
|
+
# @attribute [Numeric] Leak rate in units per second.
|
18
|
+
attr_reader :rate
|
19
|
+
|
20
|
+
# @attribute [Numeric] Maximum bucket capacity.
|
21
|
+
attr_reader :capacity
|
22
|
+
|
23
|
+
# Initialize a leaky bucket timing strategy.
|
24
|
+
# @parameter rate [Numeric] Leak rate in units per second.
|
25
|
+
# @parameter capacity [Numeric] Maximum bucket capacity.
|
26
|
+
# @parameter initial_level [Numeric] Starting bucket level (0 = leaky bucket, capacity = token bucket).
|
27
|
+
def initialize(rate, capacity, initial_level: 0)
|
28
|
+
raise ArgumentError, "rate must be non-negative" unless rate >= 0
|
29
|
+
raise ArgumentError, "capacity must be positive" unless capacity.positive?
|
30
|
+
|
31
|
+
@rate = rate
|
32
|
+
@capacity = capacity
|
33
|
+
@level = initial_level.to_f
|
34
|
+
@last_leak = Clock.now
|
35
|
+
end
|
36
|
+
|
37
|
+
# Maximum cost this timing strategy can support.
|
38
|
+
# @returns [Numeric] The maximum cost (equal to capacity).
|
39
|
+
def maximum_cost
|
40
|
+
@capacity
|
41
|
+
end
|
42
|
+
|
43
|
+
# Check if a task can be acquired with the given cost.
|
44
|
+
# @parameter cost [Numeric] The cost/weight of the operation.
|
45
|
+
# @parameter current_time [Numeric] Current time for leak calculations.
|
46
|
+
# @returns [Boolean] True if bucket has capacity for this cost.
|
47
|
+
def can_acquire?(cost = 1, current_time = Clock.now)
|
48
|
+
leak_bucket(current_time)
|
49
|
+
@level + cost <= @capacity
|
50
|
+
end
|
51
|
+
|
52
|
+
# Record that a task was acquired (adds cost to bucket level).
|
53
|
+
# @parameter cost [Numeric] The cost/weight of the operation.
|
54
|
+
def acquire(cost = 1)
|
55
|
+
leak_bucket
|
56
|
+
@level += cost
|
57
|
+
end
|
58
|
+
|
59
|
+
# Wait for bucket to have capacity.
|
60
|
+
# @parameter mutex [Mutex] Mutex to release during sleep.
|
61
|
+
# @parameter deadline [Deadline, nil] Deadline for timeout (nil = wait forever).
|
62
|
+
# @parameter cost [Numeric] Cost of the operation (default: 1).
|
63
|
+
# @returns [Boolean] True if capacity is available, false if timeout exceeded.
|
64
|
+
def wait(mutex, deadline = nil, cost = 1)
|
65
|
+
# Loop until we can acquire or deadline expires:
|
66
|
+
until can_acquire?(cost, Clock.now)
|
67
|
+
# Check deadline before each wait:
|
68
|
+
return false if deadline&.expired?
|
69
|
+
|
70
|
+
# Calculate how long to wait for bucket to leak enough for this cost:
|
71
|
+
needed_capacity = (@level + cost) - @capacity
|
72
|
+
wait_time = needed_capacity / @rate.to_f
|
73
|
+
|
74
|
+
# Should be able to acquire now:
|
75
|
+
return true if wait_time <= 0
|
76
|
+
|
77
|
+
# Check if wait would exceed deadline:
|
78
|
+
remaining = deadline&.remaining
|
79
|
+
if remaining && wait_time > remaining
|
80
|
+
# Would exceed deadline:
|
81
|
+
return false
|
82
|
+
end
|
83
|
+
|
84
|
+
# Wait for the required time (or remaining time if deadline specified):
|
85
|
+
actual_wait = remaining ? [wait_time, remaining].min : wait_time
|
86
|
+
|
87
|
+
# Release mutex during sleep:
|
88
|
+
mutex.sleep(actual_wait)
|
89
|
+
end
|
90
|
+
|
91
|
+
return true
|
92
|
+
end
|
93
|
+
|
94
|
+
# Get current bucket level (for debugging/monitoring).
|
95
|
+
# @returns [Numeric] Current bucket level.
|
96
|
+
def level
|
97
|
+
leak_bucket
|
98
|
+
@level
|
99
|
+
end
|
100
|
+
|
101
|
+
# Set bucket level (for testing purposes).
|
102
|
+
# @parameter new_level [Numeric] New bucket level.
|
103
|
+
def level=(new_level)
|
104
|
+
@level = new_level.to_f
|
105
|
+
@last_leak = Clock.now
|
106
|
+
end
|
107
|
+
|
108
|
+
# Simulate time advancement for testing purposes.
|
109
|
+
# @parameter seconds [Numeric] Number of seconds to advance.
|
110
|
+
def advance_time(seconds)
|
111
|
+
@last_leak -= seconds
|
112
|
+
leak_bucket
|
113
|
+
end
|
114
|
+
|
115
|
+
# Get current timing strategy statistics.
|
116
|
+
# @returns [Hash] Statistics hash with current state.
|
117
|
+
def statistics
|
118
|
+
leak_bucket
|
119
|
+
|
120
|
+
{
|
121
|
+
name: "LeakyBucket",
|
122
|
+
current_level: @level,
|
123
|
+
maximum_capacity: @capacity,
|
124
|
+
leak_rate: @rate,
|
125
|
+
available_capacity: @capacity - @level,
|
126
|
+
utilization_percentage: (@level / @capacity) * 100
|
127
|
+
}
|
128
|
+
end
|
129
|
+
|
130
|
+
private
|
131
|
+
|
132
|
+
# Leak the bucket based on elapsed time.
|
133
|
+
# @parameter current_time [Numeric] Current time.
|
134
|
+
def leak_bucket(current_time = Clock.now)
|
135
|
+
return if @level <= 0 # Don't leak if already empty or negative
|
136
|
+
|
137
|
+
elapsed = current_time - @last_leak
|
138
|
+
leaked = elapsed * @rate
|
139
|
+
|
140
|
+
@level = [@level - leaked, 0.0].max
|
141
|
+
@last_leak = current_time
|
142
|
+
end
|
143
|
+
end
|
144
|
+
end
|
145
|
+
end
|
146
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Released under the MIT License.
|
4
|
+
# Copyright, 2025, by Shopify Inc.
|
5
|
+
# Copyright, 2025, by Samuel Williams.
|
6
|
+
|
7
|
+
require "async/clock"
|
8
|
+
|
9
|
+
module Async
|
10
|
+
module Limiter
|
11
|
+
# @namespace
|
12
|
+
module Timing
|
13
|
+
# No timing constraints - tasks can execute immediately.
|
14
|
+
#
|
15
|
+
# This strategy provides no time-based limiting, suitable for
|
16
|
+
# pure concurrency control without rate limiting.
|
17
|
+
module None
|
18
|
+
# Maximum cost this timing strategy can support (unlimited for no constraints).
|
19
|
+
# @returns [Float] Infinity since there are no timing constraints.
|
20
|
+
def self.maximum_cost
|
21
|
+
Float::INFINITY
|
22
|
+
end
|
23
|
+
# Check if a task can be acquired (always true for no timing constraints).
|
24
|
+
# @parameter cost [Numeric] Cost of the operation (ignored for no timing constraints).
|
25
|
+
# @returns [Boolean] Always true.
|
26
|
+
def self.can_acquire?(cost = 1)
|
27
|
+
true
|
28
|
+
end
|
29
|
+
|
30
|
+
# Record that a task was acquired (no-op for this strategy).
|
31
|
+
# @parameter cost [Numeric] Cost of the operation (ignored for no timing constraints).
|
32
|
+
def self.acquire(cost = 1)
|
33
|
+
# No state to update
|
34
|
+
end
|
35
|
+
|
36
|
+
# Wait for timing constraints to be satisfied (no-op for this strategy).
|
37
|
+
# @parameter mutex [Mutex] Mutex to release during sleep (ignored for no timing constraints).
|
38
|
+
# @parameter deadline [Deadline, nil] Deadline for timeout (ignored for no timing constraints).
|
39
|
+
# @parameter cost [Numeric] Cost of the operation (ignored for no timing constraints).
|
40
|
+
# @returns [Boolean] Always true since there are no timing constraints.
|
41
|
+
def self.wait(mutex, deadline = nil, cost = 1)
|
42
|
+
# No waiting needed - return immediately
|
43
|
+
true
|
44
|
+
end
|
45
|
+
|
46
|
+
# Get current timing strategy statistics.
|
47
|
+
# @returns [Hash] Statistics hash with current state.
|
48
|
+
def self.statistics
|
49
|
+
{
|
50
|
+
name: "None"
|
51
|
+
}
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Released under the MIT License.
|
4
|
+
# Copyright, 2025, by Shopify Inc.
|
5
|
+
# Copyright, 2025, by Samuel Williams.
|
6
|
+
|
7
|
+
require "async/condition"
|
8
|
+
|
9
|
+
module Async
|
10
|
+
module Limiter
|
11
|
+
module Timing
|
12
|
+
# Ordered timing strategy wrapper that preserves FIFO ordering.
|
13
|
+
#
|
14
|
+
# This wrapper delegates to any timing strategy but ensures that tasks
|
15
|
+
# acquire capacity in the order they requested it, preventing starvation
|
16
|
+
# of high-cost operations by continuous low-cost operations.
|
17
|
+
class Ordered
|
18
|
+
# Initialize ordered timing wrapper.
|
19
|
+
# @parameter delegate [#acquire, #wait, #maximum_cost] The timing strategy to wrap.
|
20
|
+
def initialize(delegate)
|
21
|
+
@delegate = delegate
|
22
|
+
@mutex = Mutex.new
|
23
|
+
end
|
24
|
+
|
25
|
+
# Get maximum cost from delegate strategy.
|
26
|
+
# @returns [Numeric] Maximum supported cost.
|
27
|
+
def maximum_cost
|
28
|
+
@delegate.maximum_cost
|
29
|
+
end
|
30
|
+
|
31
|
+
# Record acquisition in delegate strategy.
|
32
|
+
# @parameter cost [Numeric] Cost of the operation.
|
33
|
+
def acquire(cost = 1)
|
34
|
+
@delegate.acquire(cost)
|
35
|
+
end
|
36
|
+
|
37
|
+
# Wait with FIFO ordering preserved.
|
38
|
+
# @parameter mutex [Mutex] Mutex to release during sleep.
|
39
|
+
# @parameter deadline [Deadline, nil] Deadline for timeout.
|
40
|
+
# @parameter cost [Numeric] Cost of the operation.
|
41
|
+
# @returns [Boolean] True if acquired, false if timed out.
|
42
|
+
def wait(mutex, deadline = nil, cost = 1)
|
43
|
+
@mutex.synchronize do
|
44
|
+
@delegate.wait(mutex, deadline, cost)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
# Get current timing strategy statistics from delegate.
|
49
|
+
# @returns [Hash] Statistics hash with current state.
|
50
|
+
def statistics
|
51
|
+
@delegate.statistics.tap do |statistics|
|
52
|
+
statistics[:ordered] = true
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,152 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Released under the MIT License.
|
4
|
+
# Copyright, 2025, by Shopify Inc.
|
5
|
+
# Copyright, 2025, by Samuel Williams.
|
6
|
+
|
7
|
+
require "async/clock"
|
8
|
+
require_relative "burst"
|
9
|
+
|
10
|
+
module Async
|
11
|
+
module Limiter
|
12
|
+
module Timing
|
13
|
+
# Sliding window timing strategy with rolling time periods.
|
14
|
+
#
|
15
|
+
# This strategy enforces rate limiting with a rolling window that slides continuously,
|
16
|
+
# providing smoother rate limiting than fixed windows.
|
17
|
+
class SlidingWindow
|
18
|
+
# @attribute [Numeric] Maximum tasks allowed per window.
|
19
|
+
attr_reader :limit
|
20
|
+
|
21
|
+
# @attribute [Numeric] Duration of the timing window in seconds.
|
22
|
+
attr_reader :duration
|
23
|
+
|
24
|
+
# Initialize a window timing strategy.
|
25
|
+
# @parameter duration [Numeric] Duration of the window in seconds.
|
26
|
+
# @parameter burst [#can_acquire?] Controls bursting vs smooth behavior.
|
27
|
+
# @parameter limit [Integer] Maximum tasks per window.
|
28
|
+
def initialize(duration, burst, limit)
|
29
|
+
raise ArgumentError, "duration must be positive" unless duration.positive?
|
30
|
+
|
31
|
+
@duration = duration
|
32
|
+
@burst = burst
|
33
|
+
@limit = limit
|
34
|
+
|
35
|
+
@start_time = nil
|
36
|
+
@count = 0
|
37
|
+
@frame_start_time = nil
|
38
|
+
end
|
39
|
+
|
40
|
+
# Maximum cost this timing strategy can support.
|
41
|
+
# @returns [Numeric] The maximum cost (equal to limit).
|
42
|
+
def maximum_cost
|
43
|
+
@limit
|
44
|
+
end
|
45
|
+
|
46
|
+
# Check if a task can be acquired based on window constraints.
|
47
|
+
# @parameter cost [Numeric] The cost/weight of the operation.
|
48
|
+
# @parameter current_time [Numeric] Current time for window calculations.
|
49
|
+
# @returns [Boolean] True if window and frame constraints allow acquisition.
|
50
|
+
def can_acquire?(cost = 1, current_time = Clock.now)
|
51
|
+
# Update window if needed
|
52
|
+
if window_changed?(current_time, @start_time)
|
53
|
+
@start_time = window_start_time(current_time)
|
54
|
+
@count = 0
|
55
|
+
end
|
56
|
+
|
57
|
+
frame_changed = frame_changed?(current_time)
|
58
|
+
|
59
|
+
# Check both window and frame constraints with cost
|
60
|
+
@burst.can_acquire?(@count + cost - 1, @limit, frame_changed)
|
61
|
+
end
|
62
|
+
|
63
|
+
# Record that a task was acquired.
|
64
|
+
# @parameter cost [Numeric] The cost/weight of the operation.
|
65
|
+
def acquire(cost = 1)
|
66
|
+
@count += cost
|
67
|
+
@frame_start_time = Clock.now
|
68
|
+
end
|
69
|
+
|
70
|
+
# Wait for timing constraints to be satisfied.
|
71
|
+
# Sleeps until the next window or frame becomes available, or returns immediately if ready.
|
72
|
+
# @parameter mutex [Mutex] Mutex to release during sleep.
|
73
|
+
# @parameter deadline [Deadline, nil] Deadline for timeout (nil = wait forever).
|
74
|
+
# @parameter cost [Numeric] Cost of the operation (default: 1).
|
75
|
+
# @returns [Boolean] True if constraints are satisfied, false if timeout exceeded.
|
76
|
+
def wait(mutex, deadline = nil, cost = 1)
|
77
|
+
# Only wait if we can't acquire right now:
|
78
|
+
until can_acquire?(cost, Clock.now)
|
79
|
+
# Handle non-blocking case
|
80
|
+
if deadline&.expired? || (deadline && deadline.remaining == 0)
|
81
|
+
return false
|
82
|
+
end
|
83
|
+
|
84
|
+
next_time = @burst.next_acquire_time(
|
85
|
+
@start_time,
|
86
|
+
@duration,
|
87
|
+
@frame_start_time,
|
88
|
+
@duration / @limit.to_f
|
89
|
+
)
|
90
|
+
|
91
|
+
current_time = Clock.now
|
92
|
+
wait_time = next_time - current_time
|
93
|
+
|
94
|
+
return true if wait_time <= 0
|
95
|
+
|
96
|
+
# Check if wait would exceed deadline
|
97
|
+
remaining = deadline&.remaining
|
98
|
+
if remaining && wait_time > remaining
|
99
|
+
return false # Would exceed deadline
|
100
|
+
end
|
101
|
+
|
102
|
+
# Wait for the required time (or remaining time if deadline specified)
|
103
|
+
actual_wait = remaining ? [wait_time, remaining].min : wait_time
|
104
|
+
|
105
|
+
mutex.sleep(actual_wait) # Release mutex during sleep
|
106
|
+
end
|
107
|
+
|
108
|
+
return true
|
109
|
+
end
|
110
|
+
|
111
|
+
# Calculate the start time of the current window for the given time.
|
112
|
+
# Default implementation provides sliding window behavior.
|
113
|
+
# @parameter current_time [Numeric] The current time.
|
114
|
+
# @returns [Numeric] The window start time (current time for sliding).
|
115
|
+
def window_start_time(current_time)
|
116
|
+
current_time # Sliding window: always starts now
|
117
|
+
end
|
118
|
+
|
119
|
+
# Check if the window has changed since the last window start.
|
120
|
+
# @parameter current_time [Numeric] The current time.
|
121
|
+
# @parameter last_window_start [Numeric] The previous window start time.
|
122
|
+
# @returns [Boolean] True if a new window period has begun.
|
123
|
+
def window_changed?(current_time, last_window_start)
|
124
|
+
return true if last_window_start.nil?
|
125
|
+
last_window_start + @duration <= current_time
|
126
|
+
end
|
127
|
+
|
128
|
+
# Get current timing strategy statistics.
|
129
|
+
# @returns [Hash] Statistics hash with current state.
|
130
|
+
def statistics
|
131
|
+
{
|
132
|
+
name: "SlidingWindow",
|
133
|
+
window_duration: @duration,
|
134
|
+
window_limit: @limit,
|
135
|
+
current_window_count: @count,
|
136
|
+
window_utilization_percentage: (@count.to_f / @limit) * 100,
|
137
|
+
burst: @burst.statistics,
|
138
|
+
}
|
139
|
+
end
|
140
|
+
|
141
|
+
private
|
142
|
+
|
143
|
+
def frame_changed?(current_time)
|
144
|
+
return true if @frame_start_time.nil?
|
145
|
+
|
146
|
+
frame_duration = @duration / @limit.to_f
|
147
|
+
@frame_start_time + frame_duration <= current_time
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
151
|
+
end
|
152
|
+
end
|
@@ -0,0 +1,102 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Released under the MIT License.
|
4
|
+
# Copyright, 2025, by Francisco Mejia.
|
5
|
+
# Copyright, 2025, by Shopify Inc.
|
6
|
+
# Copyright, 2025, by Samuel Williams.
|
7
|
+
|
8
|
+
module Async
|
9
|
+
module Limiter
|
10
|
+
# Token that represents an acquired resource and can be used to release or re-acquire.
|
11
|
+
#
|
12
|
+
# Tokens provide advanced resource management by encapsulating both the acquired
|
13
|
+
# resource and the acquisition options (timeout, cost, priority, etc.). This enables
|
14
|
+
# re-acquisition with modified parameters while maintaining the original context.
|
15
|
+
#
|
16
|
+
# The token automatically tracks release state using the resource itself as the
|
17
|
+
# state indicator (nil = released, non-nil = acquired).
|
18
|
+
class Token
|
19
|
+
# Acquire a token from a limiter.
|
20
|
+
#
|
21
|
+
# This class method provides a clean way to acquire tokens without
|
22
|
+
# adding token-specific methods to limiter classes.
|
23
|
+
#
|
24
|
+
# @parameter limiter [Generic] The limiter to acquire from.
|
25
|
+
# @parameter options [Hash] Acquisition options (timeout, cost, priority, etc.).
|
26
|
+
# @yields {|token| ...} Optional block executed with automatic token release.
|
27
|
+
# @parameter token [Token] The acquired token object.
|
28
|
+
# @returns [Token, nil] A token object, or nil if acquisition failed.
|
29
|
+
# @raises [ArgumentError] If cost exceeds the timing strategy's maximum supported cost.
|
30
|
+
# @asynchronous
|
31
|
+
def self.acquire(limiter, **options, &block)
|
32
|
+
resource = limiter.acquire(**options)
|
33
|
+
return nil unless resource
|
34
|
+
|
35
|
+
token = new(limiter, resource)
|
36
|
+
|
37
|
+
return token unless block_given?
|
38
|
+
|
39
|
+
begin
|
40
|
+
yield(token)
|
41
|
+
ensure
|
42
|
+
token.release
|
43
|
+
end
|
44
|
+
end
|
45
|
+
# Initialize a new token.
|
46
|
+
# @parameter limiter [Generic] The limiter that issued this token.
|
47
|
+
# @parameter resource [Object] The acquired resource.
|
48
|
+
def initialize(limiter, resource = nil)
|
49
|
+
@limiter = limiter
|
50
|
+
@resource = resource
|
51
|
+
end
|
52
|
+
|
53
|
+
# @attribute [Object] The acquired resource (nil if released).
|
54
|
+
attr_reader :resource
|
55
|
+
|
56
|
+
# Release the token back to the limiter.
|
57
|
+
def release
|
58
|
+
if resource = @resource
|
59
|
+
@resource = nil
|
60
|
+
@limiter.release(resource)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
# Re-acquire the resource with modified options.
|
65
|
+
#
|
66
|
+
# This allows changing acquisition parameters (timeout, cost, priority, etc.)
|
67
|
+
# while maintaining the token context. The current resource is released
|
68
|
+
# and a new one is acquired with the merged options.
|
69
|
+
#
|
70
|
+
# @parameter new_options [Hash] New acquisition options (timeout, cost, priority, etc.).
|
71
|
+
# These are merged with the original options, with new options taking precedence.
|
72
|
+
# @returns [Token] A new token for the re-acquired resource.
|
73
|
+
# @raises [ArgumentError] If the new cost exceeds timing strategy capacity.
|
74
|
+
# @asynchronous
|
75
|
+
def acquire(**options, &block)
|
76
|
+
raise "Token already acquired!" if @resource
|
77
|
+
|
78
|
+
@resource = @limiter.acquire(reacquire: true, **options)
|
79
|
+
|
80
|
+
return @resource unless block_given?
|
81
|
+
|
82
|
+
begin
|
83
|
+
return yield(@resource)
|
84
|
+
ensure
|
85
|
+
self.release
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
# Check if the token has been acquired.
|
90
|
+
# @returns [Boolean] True if the token has been acquired.
|
91
|
+
def acquired?
|
92
|
+
!!@resource
|
93
|
+
end
|
94
|
+
|
95
|
+
# Check if the token has been released.
|
96
|
+
# @returns [Boolean] True if the token has been released.
|
97
|
+
def released?
|
98
|
+
!@resource
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
@@ -1,5 +1,12 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Released under the MIT License.
|
4
|
+
# Copyright, 2020-2021, by Bruno Sutic.
|
5
|
+
# Copyright, 2025, by Shopify Inc.
|
6
|
+
# Copyright, 2025, by Samuel Williams.
|
7
|
+
|
1
8
|
module Async
|
2
|
-
|
3
|
-
|
4
|
-
|
9
|
+
module Limiter
|
10
|
+
VERSION = "2.0.0"
|
11
|
+
end
|
5
12
|
end
|
data/lib/async/limiter.rb
CHANGED
@@ -1,10 +1,24 @@
|
|
1
|
-
|
2
|
-
require_relative "limiter/unlimited"
|
3
|
-
require_relative "limiter/window/continuous"
|
4
|
-
require_relative "limiter/window/fixed"
|
5
|
-
require_relative "limiter/window/sliding"
|
1
|
+
# frozen_string_literal: true
|
6
2
|
|
3
|
+
# Released under the MIT License.
|
4
|
+
# Copyright, 2020, by Bruno Sutic.
|
5
|
+
# Copyright, 2025, by Shopify Inc.
|
6
|
+
# Copyright, 2025, by Samuel Williams.
|
7
|
+
|
8
|
+
require_relative "limiter/timing/none"
|
9
|
+
require_relative "limiter/timing/sliding_window"
|
10
|
+
require_relative "limiter/timing/fixed_window"
|
11
|
+
require_relative "limiter/timing/leaky_bucket"
|
12
|
+
require_relative "limiter/timing/burst"
|
13
|
+
require_relative "limiter/timing/ordered"
|
14
|
+
require_relative "limiter/generic"
|
15
|
+
require_relative "limiter/limited"
|
16
|
+
require_relative "limiter/token"
|
17
|
+
require_relative "limiter/queued"
|
18
|
+
|
19
|
+
# @namespace
|
7
20
|
module Async
|
8
|
-
|
9
|
-
|
21
|
+
# @namespace
|
22
|
+
module Limiter
|
23
|
+
end
|
10
24
|
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Released under the MIT License.
|
4
|
+
# Copyright, 2025, by Shopify Inc.
|
5
|
+
# Copyright, 2025, by Samuel Williams.
|
6
|
+
|
7
|
+
require "metrics/provider"
|
8
|
+
require "async/clock"
|
9
|
+
require_relative "../../../../async/limiter/generic"
|
10
|
+
|
11
|
+
# Metrics provider for Async::Limiter::Generic instrumentation.
|
12
|
+
# This monkey patches the Generic limiter class to add metrics around
|
13
|
+
# acquire and release operations for observability.
|
14
|
+
Metrics::Provider(Async::Limiter::Generic) do
|
15
|
+
ACQUIRE_COUNTER = Metrics.metric("async.limiter.acquire", :counter, description: "Number of limiter acquire operations.")
|
16
|
+
ACQUIRE_DURATION = Metrics.metric("async.limiter.acquire.duration", :histogram, description: "Duration of limiter acquire operations.")
|
17
|
+
ACQUIRE_ATTEMPTS = Metrics.metric("async.limiter.acquire.attempts", :counter, description: "Total number of limiter acquire attempts.")
|
18
|
+
RELEASE_COUNTER = Metrics.metric("async.limiter.release", :counter, description: "Number of limiter release operations.")
|
19
|
+
|
20
|
+
def acquire_synchronized(timeout, cost, **options)
|
21
|
+
# Build base tags and extend with instance tags if present
|
22
|
+
is_reacquire = options[:reacquire] || false
|
23
|
+
tags = ["limiter:#{self.class.name}", "cost:#{cost}", "reacquire:#{is_reacquire}"]
|
24
|
+
tags = Metrics::Tags.normalize(@tags, tags)
|
25
|
+
|
26
|
+
clock = Async::Clock.start
|
27
|
+
ACQUIRE_ATTEMPTS.emit(1, tags: tags)
|
28
|
+
|
29
|
+
begin
|
30
|
+
if result = super
|
31
|
+
# Emit success metrics
|
32
|
+
success_tags = Metrics::Tags.normalize(["result:success"], tags)
|
33
|
+
ACQUIRE_COUNTER.emit(1, tags: success_tags)
|
34
|
+
ACQUIRE_DURATION.emit(clock.total, tags: success_tags)
|
35
|
+
else
|
36
|
+
# Emit failure metrics (timeout/contention)
|
37
|
+
failure_tags = Metrics::Tags.normalize(["result:timeout"], tags)
|
38
|
+
ACQUIRE_COUNTER.emit(1, tags: failure_tags)
|
39
|
+
ACQUIRE_DURATION.emit(clock.total, tags: failure_tags)
|
40
|
+
end
|
41
|
+
|
42
|
+
return result
|
43
|
+
rescue => error
|
44
|
+
# Emit error metrics
|
45
|
+
error_tags = Metrics::Tags.normalize(["result:error", "error:#{error.class.name}"], tags)
|
46
|
+
ACQUIRE_COUNTER.emit(1, tags: error_tags)
|
47
|
+
ACQUIRE_DURATION.emit(clock.total, tags: error_tags)
|
48
|
+
|
49
|
+
raise
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
def release(resource = true)
|
54
|
+
# Build base tags and extend with instance tags if present
|
55
|
+
tags = ["limiter:#{self.class.name}"]
|
56
|
+
tags = Metrics::Tags.normalize(@tags, tags)
|
57
|
+
|
58
|
+
begin
|
59
|
+
result = super
|
60
|
+
|
61
|
+
# Emit success metrics
|
62
|
+
success_tags = Metrics::Tags.normalize(["result:success"], tags)
|
63
|
+
RELEASE_COUNTER.emit(1, tags: success_tags)
|
64
|
+
|
65
|
+
result
|
66
|
+
rescue => error
|
67
|
+
# Emit failure metrics
|
68
|
+
error_tags = Metrics::Tags.normalize(["result:error", "error:#{error.class.name}"], tags)
|
69
|
+
RELEASE_COUNTER.emit(1, tags: error_tags)
|
70
|
+
|
71
|
+
raise
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Released under the MIT License.
|
4
|
+
# Copyright, 2025, by Shopify Inc.
|
5
|
+
# Copyright, 2025, by Samuel Williams.
|
6
|
+
|
7
|
+
require "traces/provider"
|
8
|
+
require_relative "../../../../async/limiter/generic"
|
9
|
+
|
10
|
+
# Traces provider for Async::Limiter::Generic instrumentation.
|
11
|
+
# This monkey patches the Generic limiter class to add tracing around
|
12
|
+
# acquire and release operations for observability.
|
13
|
+
Traces::Provider(Async::Limiter::Generic) do
|
14
|
+
def acquire_synchronized(timeout, cost, **options)
|
15
|
+
attributes = {
|
16
|
+
"limiter" => self.class.name,
|
17
|
+
"cost" => cost,
|
18
|
+
"timeout" => timeout,
|
19
|
+
"priority" => options[:priority],
|
20
|
+
"reacquire" => options[:reacquire] || false,
|
21
|
+
}
|
22
|
+
|
23
|
+
attributes.merge!(@tags) if @tags
|
24
|
+
|
25
|
+
Traces.trace("async.limiter.acquire", attributes: attributes) do
|
26
|
+
super
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def release(resource = true)
|
31
|
+
attributes = {
|
32
|
+
"limiter.class" => self.class.name,
|
33
|
+
}
|
34
|
+
|
35
|
+
attributes.merge!(@tags) if @tags
|
36
|
+
|
37
|
+
Traces.trace("async.limiter.release", attributes: attributes) do
|
38
|
+
super
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|