async-limiter 1.5.4 → 2.1.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 +173 -0
  11. data/lib/async/limiter/limited.rb +142 -0
  12. data/lib/async/limiter/queued.rb +120 -0
  13. data/lib/async/limiter/timing/burst.rb +153 -0
  14. data/lib/async/limiter/timing/fixed_window.rb +41 -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 +86 -0
  28. data/releases.md +56 -0
  29. data.tar.gz.sig +5 -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,85 @@
1
+ # Token Usage
2
+
3
+ This guide explains how to use tokens for advanced resource management with `async-limiter`. Tokens provide sophisticated resource handling with support for re-acquisition and automatic cleanup.
4
+
5
+ ## What Are Tokens?
6
+
7
+ Tokens encapsulate acquired resources and provide advanced resource management capabilities:
8
+
9
+ - **Re-acquisition**: Re-acquire resources after release with new options.
10
+ - **Resource tracking**: Know whether a token is active or released.
11
+ - **Automatic cleanup**: Guaranteed resource release with block usage
12
+
13
+ ## Usage
14
+
15
+ Use `Token.acquire` instead of limiter-specific methods:
16
+
17
+ ```ruby
18
+ require "async"
19
+ require "async/limiter"
20
+
21
+ limiter = Async::Limiter::Limited.new(5)
22
+
23
+ # Acquire a token:
24
+ token = Async::Limiter::Token.acquire(limiter)
25
+ puts "Acquired: #{token.resource}"
26
+
27
+ # Use the resource
28
+ perform_operation(token.resource)
29
+
30
+ # Release when done:
31
+ token.release
32
+ ```
33
+
34
+ For most limiters, `token.resource` will simply be the value `true`.
35
+
36
+ ### Block-Based Token Usage
37
+
38
+ For automatic cleanup, use blocks:
39
+
40
+ ```ruby
41
+ # Automatic release with blocks:
42
+ Async::Limiter::Token.acquire(limiter) do |token|
43
+ puts "Using: #{token.resource}"
44
+ perform_operation(token.resource)
45
+ # Automatically released when block exits.
46
+ end
47
+ ```
48
+
49
+ ## Re-Acquisition
50
+
51
+ Tokens can be released and re-acquired:
52
+
53
+ ```ruby
54
+ # Initial acquisition:
55
+ token = Async::Limiter::Token.acquire(limiter)
56
+
57
+ # Use and release:
58
+ perform_operation(token.resource)
59
+ token.release
60
+
61
+ # Re-acquire later with new options:
62
+ token.acquire(priority: 5, cost: 2.0)
63
+ puts "Re-acquired: #{token.resource}"
64
+
65
+ token.release
66
+ ```
67
+
68
+ If you specify a timeout, and the limiter cannot be acquired, `nil` will be returned.
69
+
70
+ ### Re-Acquisition with Blocks
71
+
72
+ ```ruby
73
+ token = Async::Limiter::Token.acquire(limiter)
74
+
75
+ # Use the resource:
76
+ perform_operation(token.resource)
77
+ token.release
78
+
79
+ # Re-acquire and execute with automatic cleanup:
80
+ result = token.acquire(cost: 2.0) do |resource|
81
+ perform_expensive_operation(resource)
82
+ end
83
+
84
+ puts "Operation result: #{result}"
85
+ ```
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2025-2026, by Shopify Inc.
5
+ # Copyright, 2025, by Samuel Williams.
6
+
7
+ require "async/task"
8
+ require "async/deadline"
9
+ require "json"
10
+ require_relative "timing/none"
11
+ require_relative "timing/sliding_window"
12
+ require_relative "token"
13
+
14
+ module Async
15
+ module Limiter
16
+ # Generic limiter class with unlimited concurrency by default.
17
+ #
18
+ # This provides the foundation for rate limiting and concurrency control.
19
+ # Subclasses can override methods to implement specific limiting behaviors.
20
+ #
21
+ # The Generic limiter coordinates timing strategies with concurrency control,
22
+ # providing thread-safe acquisition with deadline tracking and cost-based consumption.
23
+ class Generic
24
+ # Initialize a new generic limiter.
25
+ # @parameter timing [#acquire, #wait, #maximum_cost] Strategy for timing constraints.
26
+ # @parameter parent [Async::Task, nil] Parent task for creating child tasks.
27
+ def initialize(timing: Timing::None, parent: nil, tags: nil)
28
+ @timing = timing
29
+ @parent = parent
30
+ @tags = tags
31
+
32
+ @mutex = Mutex.new
33
+ end
34
+
35
+ # @attribute [Array(String)] Tags associated with this limiter for identification or categorization.
36
+ attr :tags
37
+
38
+ # @returns [Boolean] Whether this limiter is currently limiting concurrency.
39
+ def limited?
40
+ false
41
+ end
42
+
43
+ # Execute a task asynchronously with unlimited concurrency.
44
+ # @parameter parent [Async::Task] Parent task for the new task.
45
+ # @parameter options [Hash] Additional options passed to {Async::Task#async}.
46
+ # @yields {|task| ...} The block to execute within the limiter constraints.
47
+ # @parameter task [Async::Task] The async task context.
48
+ # @returns [Async::Task] The created task.
49
+ # @asynchronous
50
+ def async(parent: (@parent || Task.current), **options)
51
+ acquire
52
+ parent.async(**options) do |task|
53
+ yield task
54
+ ensure
55
+ release
56
+ end
57
+ end
58
+
59
+ # Execute a task synchronously with unlimited concurrency.
60
+ # @yields {|task| ...} The block to execute within the limiter constraints.
61
+ # @parameter task [Async::Task] The current task context.
62
+ # @asynchronous
63
+ def sync
64
+ acquire do
65
+ yield(Task.current)
66
+ end
67
+ end
68
+
69
+ # Manually acquire a resource with timing and concurrency coordination.
70
+ #
71
+ # This method provides the core acquisition logic with support for:
72
+ # - Flexible timeout handling (blocking, non-blocking, timed)
73
+ # - Cost-based consumption for timing strategies
74
+ # - Deadline tracking to prevent timeout violations
75
+ # - Automatic resource cleanup with block usage
76
+ #
77
+ # @parameter timeout [Numeric, nil] Timeout in seconds (nil = wait forever, 0 = non-blocking).
78
+ # @parameter cost [Numeric] The cost/weight of this operation for timing strategies (default: 1).
79
+ # @parameter options [Hash] Additional options passed to concurrency acquisition.
80
+ # @yields {|resource| ...} Optional block executed with automatic resource release.
81
+ # @parameter resource [Object] The acquired resource.
82
+ # @returns [Object, nil] The acquired resource, or nil if acquisition failed/timed out.
83
+ # When used with a block, returns the result of the block execution.
84
+ # @raises [ArgumentError] If cost exceeds the timing strategy's maximum supported cost.
85
+ # @asynchronous
86
+ def acquire(timeout: nil, cost: 1, **options)
87
+ # Validate cost against timing strategy capacity:
88
+ maximum_cost = @timing.maximum_cost
89
+ if cost > maximum_cost
90
+ raise ArgumentError, "Cost #{cost} exceeds maximum supported cost #{maximum_cost} for timing strategy!"
91
+ end
92
+
93
+ resource = nil
94
+ deadline = Deadline.start(timeout)
95
+
96
+ # Atomically handle timing constraints and concurrency logic:
97
+ acquire_synchronized(timeout, cost, **options) do
98
+ # Wait for timing constraints to be satisfied (mutex released during sleep)
99
+ return nil unless @timing.wait(@mutex, deadline, cost)
100
+
101
+ # Execute the concurrency-specific acquisition logic
102
+ resource = acquire_resource(deadline, **options)
103
+
104
+ # Record timing acquisition if successful
105
+ if resource
106
+ @timing.acquire(cost)
107
+ else
108
+ # `acquire_concurrency` should return nil if deadline reached:
109
+ return nil
110
+ end
111
+
112
+ resource
113
+ end
114
+
115
+ return resource unless block_given?
116
+
117
+ begin
118
+ yield(resource)
119
+ ensure
120
+ release(resource)
121
+ end
122
+ end
123
+
124
+ # Release a previously acquired resource.
125
+ def release(resource = true)
126
+ release_resource(resource)
127
+ end
128
+
129
+ # Get current limiter statistics.
130
+ # @returns [Hash] Statistics hash with current state.
131
+ def statistics
132
+ @mutex.synchronize do
133
+ {
134
+ timing: @timing.statistics
135
+ }
136
+ end
137
+ end
138
+
139
+ # Get a JSON-compatible representation of the limiter statistics.
140
+ # @returns [Hash] Statistics hash with current state.
141
+ def as_json(...)
142
+ statistics
143
+ end
144
+
145
+ # Get a JSON string representation of the limiter statistics.
146
+ # @returns [String] JSON encoded statistics.
147
+ def to_json(...)
148
+ as_json.to_json(...)
149
+ end
150
+
151
+ protected
152
+
153
+ def acquire_synchronized(timeout, cost, **options)
154
+ @mutex.synchronize do
155
+ yield
156
+ end
157
+ end
158
+
159
+ # Default resource acquisition for unlimited semaphore.
160
+ # Subclasses should override this method.
161
+ def acquire_resource(deadline, **options)
162
+ # Default unlimited behavior - always succeeds
163
+ true
164
+ end
165
+
166
+ # Default resource release for unlimited semaphore.
167
+ # Subclasses should override this method.
168
+ def release_resource(resource)
169
+ # Default implementation - subclasses should override.
170
+ end
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2020, by Bruno Sutic.
5
+ # Copyright, 2025-2026, by Shopify Inc.
6
+ # Copyright, 2025, by Samuel Williams.
7
+
8
+ require_relative "generic"
9
+
10
+ module Async
11
+ module Limiter
12
+ # Limited concurrency limiter that enforces a strict task limit.
13
+ #
14
+ # This implements a counting semaphore that limits the number of concurrent
15
+ # operations. It coordinates with timing strategies to provide both concurrency
16
+ # and rate limiting.
17
+ #
18
+ # The Limited limiter uses a mutex and condition variable for thread-safe
19
+ # coordination, with support for deadline-aware timeout handling.
20
+ class Limited < Generic
21
+ # Initialize a limited concurrency limiter.
22
+ # @parameter limit [Integer] Maximum concurrent tasks allowed.
23
+ # @parameter timing [#acquire, #wait, #maximum_cost] Strategy for timing constraints.
24
+ # @parameter parent [Async::Task, nil] Parent task for creating child tasks.
25
+ # @raises [ArgumentError] If limit is not positive.
26
+ def initialize(limit = 1, timing: Timing::None, parent: nil)
27
+ super(timing: timing, parent: parent)
28
+
29
+ @limit = limit
30
+ @count = 0
31
+ @waiting_count = 0
32
+ @reacquire_waiting_count = 0
33
+
34
+ @available = ConditionVariable.new
35
+ end
36
+
37
+ # @attribute [Integer] The maximum number of concurrent tasks.
38
+ attr_reader :limit
39
+
40
+ # @attribute [Integer] Current count of active tasks.
41
+ attr_reader :count
42
+
43
+ # @returns [Integer] Current count of active tasks.
44
+ def acquired_count
45
+ @mutex.synchronize{@count}
46
+ end
47
+
48
+ # @returns [Integer] Current count of available capacity.
49
+ def available_count
50
+ @mutex.synchronize{@limit - @count}
51
+ end
52
+
53
+ # @returns [Integer] Current count of tasks waiting for capacity.
54
+ def waiting_count
55
+ @mutex.synchronize{@waiting_count}
56
+ end
57
+
58
+ # @returns [Integer] Current count of reacquiring tasks waiting for capacity.
59
+ def reacquire_waiting_count
60
+ @mutex.synchronize{@reacquire_waiting_count}
61
+ end
62
+
63
+ # Check if a new task can be acquired.
64
+ # @returns [Boolean] True if under the limit.
65
+ def limited?
66
+ @mutex.synchronize{@count >= @limit}
67
+ end
68
+
69
+ # Update the concurrency limit.
70
+ # @parameter new_limit [Integer] The new maximum number of concurrent tasks.
71
+ # @raises [ArgumentError] If new_limit is not positive.
72
+ def limit=(new_limit)
73
+ @mutex.synchronize do
74
+ old_limit = @limit
75
+ @limit = new_limit
76
+
77
+ # Wake up waiting tasks if limit increased:
78
+ @available.broadcast if new_limit > old_limit
79
+ end
80
+ end
81
+
82
+ # Get current limiter statistics.
83
+ # @returns [Hash] Statistics hash with current state.
84
+ def statistics
85
+ @mutex.synchronize do
86
+ {
87
+ limit: @limit,
88
+ count: @count,
89
+ acquired_count: @count,
90
+ available_count: @limit - @count,
91
+ waiting_count: @waiting_count,
92
+ reacquire_waiting_count: @reacquire_waiting_count,
93
+ timing: @timing.statistics
94
+ }
95
+ end
96
+ end
97
+
98
+ protected
99
+
100
+ # Acquire resource with optional deadline.
101
+ def acquire_resource(deadline, reacquire: false, **options)
102
+ # Fast path: immediate return for expired deadlines, but only if at capacity
103
+ return nil if deadline&.expired? && @count >= @limit
104
+
105
+ waiting = false
106
+
107
+ # Wait for capacity with deadline tracking
108
+ while @count >= @limit
109
+ remaining = deadline&.remaining
110
+ return nil if remaining && remaining <= 0
111
+
112
+ unless waiting
113
+ @waiting_count += 1
114
+ @reacquire_waiting_count += 1 if reacquire
115
+ waiting = true
116
+ end
117
+
118
+ unless @available.wait(@mutex, remaining)
119
+ return nil # Timeout exceeded
120
+ end
121
+ end
122
+
123
+ @count += 1
124
+
125
+ return true
126
+ ensure
127
+ if waiting
128
+ @waiting_count -= 1
129
+ @reacquire_waiting_count -= 1 if reacquire
130
+ end
131
+ end
132
+
133
+ # Release resource.
134
+ def release_resource(resource)
135
+ @mutex.synchronize do
136
+ @count -= 1
137
+ @available.signal
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2025, by Francisco Mejia.
5
+ # Copyright, 2025-2026, by Shopify Inc.
6
+ # Copyright, 2025, by Samuel Williams.
7
+
8
+ require_relative "generic"
9
+ require_relative "token"
10
+
11
+ module Async
12
+ module Limiter
13
+ # Queue-based limiter that distributes pre-existing resources with priority/timeout support.
14
+ #
15
+ # This limiter manages a finite set of resources (connections, API keys, etc.)
16
+ # that are pre-populated in a queue. It provides priority-based allocation
17
+ # and timeout support for resource acquisition.
18
+ #
19
+ # Unlike Limited which counts abstract permits, Queued distributes actual
20
+ # resource objects and supports priority queues for fair allocation.
21
+ class Queued < Generic
22
+ # @returns [Queue] A default queue with a single true value.
23
+ def self.default_queue
24
+ Queue.new.tap do |queue|
25
+ queue.push(true)
26
+ end
27
+ end
28
+
29
+ # Initialize a queue-based limiter.
30
+ # @parameter queue [#push, #pop, #empty?] Thread-safe queue containing pre-existing resources.
31
+ # @parameter timing [#acquire, #wait, #maximum_cost] Strategy for timing constraints.
32
+ # @parameter parent [Async::Task, nil] Parent task for creating child tasks.
33
+ def initialize(queue = self.class.default_queue, timing: Timing::None, parent: nil)
34
+ super(timing: timing, parent: parent)
35
+ @queue = queue
36
+ @acquired_count = 0
37
+ @reacquire_waiting_count = 0
38
+ end
39
+
40
+ # @attribute [Queue] The queue managing resources.
41
+ attr_reader :queue
42
+
43
+ # @returns [Integer] Current count of acquired resources.
44
+ def acquired_count
45
+ @mutex.synchronize{@acquired_count}
46
+ end
47
+
48
+ # @returns [Integer] Current count of available resources.
49
+ def available_count
50
+ @queue.size
51
+ end
52
+
53
+ # @returns [Integer] Current count of tasks waiting for resources.
54
+ def waiting_count
55
+ @queue.waiting_count
56
+ end
57
+
58
+ # @returns [Integer] Current count of reacquiring tasks waiting for resources.
59
+ def reacquire_waiting_count
60
+ @mutex.synchronize{@reacquire_waiting_count}
61
+ end
62
+
63
+ # Check if a new task can be acquired.
64
+ # @returns [Boolean] True if resources are available.
65
+ def limited?
66
+ @queue.empty?
67
+ end
68
+
69
+ # Expand the queue with additional resources.
70
+ # @parameter count [Integer] Number of resources to add.
71
+ # @parameter value [Object] The value to add to the queue.
72
+ def expand(count, value = true)
73
+ count.times do
74
+ @queue.push(value)
75
+ end
76
+ end
77
+
78
+ # Get current limiter statistics.
79
+ # @returns [Hash] Statistics hash with current state.
80
+ def statistics
81
+ @mutex.synchronize do
82
+ {
83
+ waiting: @queue.waiting_count,
84
+ available: @queue.size,
85
+ acquired_count: @acquired_count,
86
+ available_count: @queue.size,
87
+ waiting_count: @queue.waiting_count,
88
+ reacquire_waiting_count: @reacquire_waiting_count,
89
+ timing: @timing.statistics
90
+ }
91
+ end
92
+ end
93
+
94
+ protected
95
+
96
+ # Acquire a resource from the queue with optional deadline.
97
+ def acquire_resource(deadline, reacquire: false, **options)
98
+ @reacquire_waiting_count += 1 if reacquire
99
+
100
+ @mutex.unlock
101
+ resource = @queue.pop(timeout: deadline&.remaining, **options)
102
+ return resource
103
+ ensure
104
+ @mutex.lock
105
+ @reacquire_waiting_count -= 1 if reacquire
106
+ @acquired_count += 1 if resource
107
+ end
108
+
109
+ # Release a previously acquired resource back to the queue.
110
+ def release_resource(value)
111
+ @mutex.synchronize do
112
+ @acquired_count -= 1 if @acquired_count > 0
113
+ end
114
+
115
+ # Return a default resource to the queue:
116
+ @queue.push(value)
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,153 @@
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
+ module Async
8
+ module Limiter
9
+ module Timing
10
+ # Provides burst control strategies for timing limiters.
11
+ #
12
+ # Burst strategies are stateless modules that determine whether tasks can execute
13
+ # immediately or must wait for frame boundaries, controlling task distribution over time.
14
+ #
15
+ # ## Strategy Comparison
16
+ #
17
+ # Greedy vs Smooth behavior with 5 tasks per 60-second window:
18
+ #
19
+ # Greedy Strategy (allows clustering):
20
+ # |█████ |█████ |█████ |█████ |
21
+ # 0s 60s 120s 180s
22
+ # All 5 tasks execute immediately when window opens
23
+ #
24
+ # Smooth Strategy (enforces even distribution):
25
+ # |█ █ █ █ █ |█ █ █ █ █ |█ █ █ █ █ |█ █ █ █ █ |
26
+ # 0s 60s 120s 180s
27
+ # Tasks spread evenly: 0s, 12s, 24s, 36s, 48s
28
+ #
29
+ module Burst
30
+ # Allows tasks to cluster within windows for high-throughput scenarios.
31
+ #
32
+ # Greedy strategies permit multiple tasks to start immediately as long as
33
+ # the window limit hasn't been exceeded. This creates "bursts" of activity
34
+ # at window boundaries, maximizing throughput when resources become available.
35
+ #
36
+ # ## Greedy Behavior
37
+ #
38
+ # Greedy behavior with 3 tasks per 10-second window:
39
+ #
40
+ # Window 1: [Task1, Task2, Task3] at 0s -------- (all immediate)
41
+ # Window 2: [Task4, Task5, Task6] at 10s ------- (all immediate)
42
+ #
43
+ # Perfect for: Batch processing, high-throughput scenarios
44
+ module Greedy
45
+ # Check if a task can be acquired in burstable mode.
46
+ # @parameter window_count [Integer] Number of tasks started in current window.
47
+ # @parameter limit [Integer] Maximum tasks allowed in the window.
48
+ # @parameter frame_changed [Boolean] Ignored in burstable mode.
49
+ # @returns [Boolean] True if under the window limit.
50
+ def self.can_acquire?(window_count, limit, frame_changed)
51
+ window_count < limit
52
+ end
53
+
54
+ # Calculate the next time a task can be acquired.
55
+ # @parameter window_start_time [Numeric] When the current window started.
56
+ # @parameter window_duration [Numeric] Duration of the window.
57
+ # @parameter frame_start_time [Numeric] Ignored in burstable mode.
58
+ # @parameter frame_duration [Numeric] Ignored in burstable mode.
59
+ # @returns [Numeric] The next window start time.
60
+ def self.next_acquire_time(window_start_time, window_duration, frame_start_time, frame_duration)
61
+ window_start_time + window_duration
62
+ end
63
+
64
+ # Check if window constraints are blocking new tasks.
65
+ # @parameter window_count [Integer] Number of tasks started in current window.
66
+ # @parameter limit [Integer] Maximum tasks allowed in the window.
67
+ # @parameter window_changed [Boolean] Whether the window has reset.
68
+ # @returns [Boolean] True if window is blocking new tasks.
69
+ def self.window_blocking?(window_count, limit, window_changed)
70
+ return false if window_changed
71
+ window_count >= limit
72
+ end
73
+
74
+ # Check if frame constraints are blocking new tasks.
75
+ # @parameter frame_changed [Boolean] Whether the frame boundary has been crossed.
76
+ # @returns [Boolean] Always false for burstable mode.
77
+ def self.frame_blocking?(frame_changed)
78
+ false # Burstable mode doesn't use frame blocking
79
+ end
80
+
81
+ # Get current burst strategy statistics.
82
+ # @returns [Hash] Statistics hash with current state.
83
+ def self.statistics
84
+ {
85
+ name: "Greedy",
86
+ }
87
+ end
88
+ end
89
+
90
+ # Enforces even task distribution to prevent clustering.
91
+ #
92
+ # Smooth strategies prevent clustering by requiring tasks to wait for
93
+ # frame boundaries, ensuring smooth and predictable task execution timing.
94
+ # This creates consistent load patterns and prevents resource spikes.
95
+ #
96
+ # ## Smooth Behavior
97
+ #
98
+ # Smooth behavior with 3 tasks per 15-second window:
99
+ #
100
+ # Window 1: [Task1] -- [Task2] -- [Task3] ---- (evenly spaced)
101
+ # 0s 5s 10s 15s
102
+ # Window 2: [Task4] -- [Task5] -- [Task6] ---- (evenly spaced)
103
+ # 15s 20s 25s 30s
104
+ #
105
+ # Perfect for: API rate limiting, smooth resource usage
106
+ module Smooth
107
+ # Check if a task can be acquired in continuous mode.
108
+ # @parameter window_count [Integer] Ignored in continuous mode.
109
+ # @parameter limit [Integer] Ignored in continuous mode.
110
+ # @parameter frame_changed [Boolean] Whether the frame boundary has been crossed.
111
+ # @returns [Boolean] True only if the frame boundary has been crossed.
112
+ def self.can_acquire?(window_count, limit, frame_changed)
113
+ frame_changed
114
+ end
115
+
116
+ # Calculate the next time a task can be acquired.
117
+ # @parameter window_start_time [Numeric] Ignored in continuous mode.
118
+ # @parameter window_duration [Numeric] Ignored in continuous mode.
119
+ # @parameter frame_start_time [Numeric] When the current frame started.
120
+ # @parameter frame_duration [Numeric] Duration of each frame.
121
+ # @returns [Numeric] The next frame start time.
122
+ def self.next_acquire_time(window_start_time, window_duration, frame_start_time, frame_duration)
123
+ frame_start_time + frame_duration
124
+ end
125
+
126
+ # Check if window constraints are blocking new tasks.
127
+ # @parameter window_count [Integer] Ignored in continuous mode.
128
+ # @parameter limit [Integer] Ignored in continuous mode.
129
+ # @parameter window_changed [Boolean] Ignored in continuous mode.
130
+ # @returns [Boolean] Always false for continuous mode.
131
+ def self.window_blocking?(window_count, limit, window_changed)
132
+ false # Continuous mode doesn't use window blocking
133
+ end
134
+
135
+ # Check if frame constraints are blocking new tasks.
136
+ # @parameter frame_changed [Boolean] Whether the frame boundary has been crossed.
137
+ # @returns [Boolean] True if frame hasn't changed (blocking until next frame).
138
+ def self.frame_blocking?(frame_changed)
139
+ !frame_changed
140
+ end
141
+
142
+ # Get current burst strategy statistics.
143
+ # @returns [Hash] Statistics hash with current state.
144
+ def self.statistics
145
+ {
146
+ name: "Smooth",
147
+ }
148
+ end
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end