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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/context/generic-limiter.md +167 -0
  4. data/context/getting-started.md +226 -0
  5. data/context/index.yaml +41 -0
  6. data/context/limited-limiter.md +184 -0
  7. data/context/queued-limiter.md +109 -0
  8. data/context/timing-strategies.md +666 -0
  9. data/context/token-usage.md +85 -0
  10. data/lib/async/limiter/generic.rb +160 -0
  11. data/lib/async/limiter/limited.rb +103 -0
  12. data/lib/async/limiter/queued.rb +85 -0
  13. data/lib/async/limiter/timing/burst.rb +153 -0
  14. data/lib/async/limiter/timing/fixed_window.rb +42 -0
  15. data/lib/async/limiter/timing/leaky_bucket.rb +146 -0
  16. data/lib/async/limiter/timing/none.rb +56 -0
  17. data/lib/async/limiter/timing/ordered.rb +58 -0
  18. data/lib/async/limiter/timing/sliding_window.rb +152 -0
  19. data/lib/async/limiter/token.rb +102 -0
  20. data/lib/async/limiter/version.rb +10 -3
  21. data/lib/async/limiter.rb +21 -7
  22. data/lib/metrics/provider/async/limiter/generic.rb +74 -0
  23. data/lib/metrics/provider/async/limiter.rb +7 -0
  24. data/lib/traces/provider/async/limiter/generic.rb +41 -0
  25. data/lib/traces/provider/async/limiter.rb +7 -0
  26. data/license.md +25 -0
  27. data/readme.md +45 -0
  28. data/releases.md +50 -0
  29. data.tar.gz.sig +0 -0
  30. metadata +68 -83
  31. metadata.gz.sig +0 -0
  32. data/lib/async/limiter/concurrent.rb +0 -101
  33. data/lib/async/limiter/constants.rb +0 -6
  34. data/lib/async/limiter/unlimited.rb +0 -53
  35. data/lib/async/limiter/window/continuous.rb +0 -21
  36. data/lib/async/limiter/window/fixed.rb +0 -21
  37. data/lib/async/limiter/window/sliding.rb +0 -21
  38. 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
- module Limiter
3
- VERSION = "1.5.4"
4
- end
9
+ module Limiter
10
+ VERSION = "2.0.0"
11
+ end
5
12
  end
data/lib/async/limiter.rb CHANGED
@@ -1,10 +1,24 @@
1
- require_relative "limiter/concurrent"
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
- module Limiter
9
- end
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,7 @@
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_relative "limiter/generic"
@@ -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
@@ -0,0 +1,7 @@
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_relative "limiter/generic"