async-limiter 0.0.1 → 1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c1e4bf6b1b1477475fc5bf247364592ebd062e86cd5039614a9963cfdc9e23ab
4
- data.tar.gz: 82f5fc81a61d6d8cc0400d86b4a84977379f3edc8f6951bba738e2215e1e3445
3
+ metadata.gz: 6d203b60d1b006ce7dc199fd37dbfeac43468324a083a02a233dbf7fe1ebc61f
4
+ data.tar.gz: f7cd17453e047205743648458058f30365d795514fba956dca1aa910ab34816e
5
5
  SHA512:
6
- metadata.gz: 8c2698448ec6846405dc647c0e11b37616fbd80027b92a03e28a849d511164835909d4982ddbe6cfff7e963b00bedc6699dd90dbc33067f52969480170bc1c4a
7
- data.tar.gz: 62e777daf244f5cfe6b9ef89cdcdd8b07791808324713db58a7242825cf9d3d82f4978c920639bbc603dae625fbe819a41fdb7b95897d3f619d8d80eb9195794
6
+ metadata.gz: 2721ba28df1d8aca742ba974ace3146d136734e9482c39429957dd0ff9022843eef5dc6bdbda9c9aa60017c019ab2011907a31a6d30d8a16f7392b2c70daf04a
7
+ data.tar.gz: 7ea2cbb70d1f192d947d42594711fdf37da8f86a56d624925ec3b8fdb31957248ecbb1b945f62f7c4dfc755c947ede0ec65f8035c2806fd942ebd60ab430194d
@@ -1,114 +1,10 @@
1
- require "async/task"
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"
2
6
 
3
7
  module Async
4
- # Base class for all the limiters.
5
- class Limiter
6
- Error = Class.new(StandardError)
7
- ArgumentError = Class.new(Error)
8
-
9
- MAX_LIMIT = Float::INFINITY
10
- MIN_LIMIT = Float::MIN
11
-
12
- attr_reader :count
13
-
14
- attr_reader :limit
15
-
16
- attr_reader :waiting
17
-
18
- def initialize(limit = 1, parent: nil,
19
- max_limit: MAX_LIMIT, min_limit: MIN_LIMIT)
20
- @count = 0
21
- @limit = limit
22
- @waiting = []
23
- @parent = parent
24
- @max_limit = max_limit
25
- @min_limit = min_limit
26
-
27
- validate!
28
- end
29
-
30
- def blocking?
31
- @count >= @limit
32
- end
33
-
34
- def async(parent: (@parent or Task.current), **options)
35
- acquire
36
- parent.async(**options) do |task|
37
- yield task
38
- ensure
39
- release
40
- end
41
- end
42
-
43
- def acquire
44
- wait
45
- @count += 1
46
- end
47
-
48
- def release
49
- @count -= 1
50
-
51
- while under_limit? && (fiber = @waiting.shift)
52
- fiber.resume if fiber.alive?
53
- end
54
- end
55
-
56
- def increase_limit(number = 1)
57
- new_limit = @limit + number
58
- return false if new_limit > @max_limit
59
-
60
- @limit = new_limit
61
- end
62
-
63
- def decrease_limit(number = 1)
64
- new_limit = @limit - number
65
- return false if new_limit < @min_limit
66
-
67
- @limit = new_limit
68
- end
69
-
70
- def waiting_count
71
- @waiting.size
72
- end
73
-
74
- private
75
-
76
- def under_limit?
77
- available_units.positive?
78
- end
79
-
80
- def available_units
81
- @limit - @count
82
- end
83
-
84
- def wait
85
- fiber = Fiber.current
86
-
87
- if blocking?
88
- @waiting << fiber
89
- Task.yield while blocking?
90
- end
91
- rescue Exception
92
- @waiting.delete(fiber)
93
- raise
94
- end
95
-
96
- def validate!
97
- if @max_limit < @min_limit
98
- raise ArgumentError, "max_limit is lower than min_limit"
99
- end
100
-
101
- unless @max_limit.positive?
102
- raise ArgumentError, "max_limit must be positive"
103
- end
104
-
105
- unless @min_limit.positive?
106
- raise ArgumentError, "min_limit must be positive"
107
- end
108
-
109
- unless @limit.between?(@min_limit, @max_limit)
110
- raise ArgumentError, "limit not between min_limit and max_limit"
111
- end
112
- end
8
+ module Limiter
113
9
  end
114
10
  end
@@ -1,10 +1,87 @@
1
- require_relative "../limiter"
1
+ require "async/task"
2
+ require_relative "constants"
2
3
 
3
4
  module Async
4
- class Limiter
5
- # Allows running x units of work concurrently.
6
- # Has the same logic as Async::Semaphore.
7
- class Concurrent < Limiter
5
+ module Limiter
6
+ class Concurrent
7
+ attr_reader :count
8
+
9
+ attr_reader :limit
10
+
11
+ def initialize(limit = 1, parent: nil)
12
+ @count = 0
13
+ @limit = limit
14
+ @waiting = []
15
+ @parent = parent
16
+
17
+ validate!
18
+ end
19
+
20
+ def blocking?
21
+ limit_blocking?
22
+ end
23
+
24
+ def async(parent: (@parent || Task.current), **options)
25
+ acquire
26
+ parent.async(**options) do |task|
27
+ yield task
28
+ ensure
29
+ release
30
+ end
31
+ end
32
+
33
+ def acquire
34
+ wait
35
+ @count += 1
36
+ end
37
+
38
+ def release
39
+ @count -= 1
40
+
41
+ resume_waiting
42
+ end
43
+
44
+ def limit=(new_limit)
45
+ validate_limit!(new_limit)
46
+
47
+ @limit = new_limit
48
+ end
49
+
50
+ private
51
+
52
+ def limit_blocking?
53
+ @count >= @limit
54
+ end
55
+
56
+ def wait
57
+ fiber = Fiber.current
58
+
59
+ if blocking?
60
+ @waiting << fiber
61
+ Task.yield while blocking?
62
+ end
63
+ rescue Exception # rubocop:disable Lint/RescueException
64
+ @waiting.delete(fiber)
65
+ raise
66
+ end
67
+
68
+ def resume_waiting
69
+ while !blocking? && (fiber = @waiting.shift)
70
+ fiber.resume if fiber.alive?
71
+ end
72
+ end
73
+
74
+ def validate!
75
+ if @limit.finite? && (@limit % 1).nonzero?
76
+ raise ArgumentError, "limit must be a whole number"
77
+ end
78
+
79
+ validate_limit!
80
+ end
81
+
82
+ def validate_limit!(value = @limit)
83
+ raise ArgumentError, "limit must be greater than 1" if value < 1
84
+ end
8
85
  end
9
86
  end
10
87
  end
@@ -0,0 +1,6 @@
1
+ module Async
2
+ module Limiter
3
+ Error = Class.new(StandardError)
4
+ ArgumentError = Class.new(Error)
5
+ end
6
+ end
@@ -0,0 +1,39 @@
1
+ require "async/task"
2
+
3
+ module Async
4
+ module Limiter
5
+ class Unlimited
6
+ attr_reader :count
7
+
8
+ def initialize(parent: nil)
9
+ @count = 0
10
+ @parent = parent
11
+ end
12
+
13
+ def limit
14
+ Float::INFINITY
15
+ end
16
+
17
+ def blocking?
18
+ false
19
+ end
20
+
21
+ def async(parent: (@parent || Task.current), **options)
22
+ acquire
23
+ parent.async(**options) do |task|
24
+ yield task
25
+ ensure
26
+ release
27
+ end
28
+ end
29
+
30
+ def acquire
31
+ @count += 1
32
+ end
33
+
34
+ def release
35
+ @count -= 1
36
+ end
37
+ end
38
+ end
39
+ end
@@ -1,5 +1,5 @@
1
1
  module Async
2
- class Limiter
3
- VERSION = "0.0.1"
2
+ module Limiter
3
+ VERSION = "1.0.0"
4
4
  end
5
5
  end
@@ -0,0 +1,263 @@
1
+ require "async/clock"
2
+ require "async/task"
3
+ require_relative "constants"
4
+
5
+ module Async
6
+ module Limiter
7
+ class Window
8
+ TYPES = %i[fixed sliding].freeze
9
+ NULL_TIME = -1
10
+
11
+ attr_reader :count
12
+
13
+ attr_reader :type
14
+
15
+ attr_reader :lock
16
+
17
+ def initialize(limit = 1, type: :fixed, window: 1, parent: nil,
18
+ burstable: true, lock: true)
19
+ @count = 0
20
+ @input_limit = @limit = limit
21
+ @type = type
22
+ @input_window = @window = window
23
+ @parent = parent
24
+ @burstable = burstable
25
+ @lock = lock
26
+
27
+ @waiting = []
28
+ @scheduler_task = nil
29
+
30
+ @window_frame_start_time = NULL_TIME
31
+ @window_start_time = NULL_TIME
32
+ @window_count = 0
33
+
34
+ update_concurrency
35
+ validate!
36
+ end
37
+
38
+ def limit
39
+ @input_limit
40
+ end
41
+
42
+ def window
43
+ @input_window
44
+ end
45
+
46
+ def blocking?
47
+ limit_blocking? || window_blocking? || window_frame_blocking?
48
+ end
49
+
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
+ def acquire
60
+ wait
61
+ @count += 1
62
+
63
+ current_time = Clock.now
64
+
65
+ if window_changed?(current_time)
66
+ @window_start_time =
67
+ if @type == :sliding
68
+ current_time
69
+ elsif @type == :fixed
70
+ (current_time / @window).to_i * @window
71
+ else
72
+ raise "invalid type #{@type}"
73
+ end
74
+
75
+ @window_count = 1
76
+ else
77
+ @window_count += 1
78
+ end
79
+
80
+ @window_frame_start_time = current_time
81
+ end
82
+
83
+ def release
84
+ @count -= 1
85
+
86
+ # We're resuming waiting fibers when lock is released.
87
+ resume_waiting if @lock
88
+ end
89
+
90
+ def limit=(new_limit)
91
+ validate_limit!(new_limit)
92
+ @input_limit = @limit = new_limit
93
+
94
+ update_concurrency
95
+ resume_waiting
96
+ reschedule if reschedule?
97
+
98
+ limit
99
+ end
100
+
101
+ def window=(new_window)
102
+ validate_window!(new_window)
103
+ @input_window = @window = new_window
104
+
105
+ update_concurrency
106
+ resume_waiting
107
+ reschedule if reschedule?
108
+
109
+ window
110
+ end
111
+
112
+ private
113
+
114
+ def limit_blocking?
115
+ @lock && @count >= @limit
116
+ end
117
+
118
+ def window_blocking?
119
+ return false unless @burstable
120
+ return false if window_changed?
121
+
122
+ @window_count >= @limit
123
+ end
124
+
125
+ def window_frame_blocking?
126
+ return false if @burstable
127
+ return false if window_frame_changed?
128
+
129
+ true
130
+ end
131
+
132
+ def window_changed?(time = Clock.now)
133
+ @window_start_time + @window <= time
134
+ end
135
+
136
+ def window_frame_changed?
137
+ @window_frame_start_time + window_frame <= Clock.now
138
+ end
139
+
140
+ def wait
141
+ fiber = Fiber.current
142
+
143
+ # @waiting.any? check prevents fibers resumed via scheduler from
144
+ # slipping in operations before other waiting fibers get resumed.
145
+ if blocking? || @waiting.any?
146
+ @waiting << fiber
147
+ schedule if schedule?
148
+ loop do
149
+ Task.yield # run this line at least once
150
+ break unless blocking?
151
+ end
152
+ end
153
+ rescue Exception # rubocop:disable Lint/RescueException
154
+ @waiting.delete(fiber)
155
+ raise
156
+ end
157
+
158
+ def schedule?
159
+ @scheduler_task.nil? &&
160
+ @waiting.any? &&
161
+ !limit_blocking?
162
+ end
163
+
164
+ # Schedule resuming waiting tasks.
165
+ def schedule(parent: @parent || Task.current)
166
+ @scheduler_task ||=
167
+ parent.async { |task|
168
+ while @waiting.any? && !limit_blocking?
169
+ delay = [next_acquire_time - Async::Clock.now, 0].max
170
+ task.sleep(delay) if delay.positive?
171
+ resume_waiting
172
+ end
173
+
174
+ @scheduler_task = nil
175
+ }
176
+ end
177
+
178
+ def reschedule?
179
+ @scheduler_task &&
180
+ @waiting.any? &&
181
+ !limit_blocking?
182
+ end
183
+
184
+ def reschedule
185
+ @scheduler_task.stop
186
+ @scheduler_task = nil
187
+
188
+ schedule
189
+ end
190
+
191
+ def resume_waiting
192
+ while !blocking? && (fiber = @waiting.shift)
193
+ fiber.resume if fiber.alive?
194
+ end
195
+
196
+ # Long running non-burstable tasks may end while
197
+ # #window_frame_blocking?. Start a scheduler if one is not running.
198
+ schedule if schedule?
199
+ end
200
+
201
+ def next_acquire_time
202
+ if @burstable
203
+ @window_start_time + @window # next window start time
204
+ else
205
+ @window_frame_start_time + window_frame # next window frame start time
206
+ end
207
+ end
208
+
209
+ def window_frame
210
+ @window.to_f / @limit
211
+ end
212
+
213
+ # If limit is a decimal number (e.g. 0.5) it needs to be adjusted.
214
+ # Make @limit a whole number and adjust @window appropriately.
215
+ def update_concurrency
216
+ # reset @limit and @window
217
+ @limit = @input_limit
218
+ @window = @input_window
219
+
220
+ return if @input_limit.infinite?
221
+ return if (@input_limit % 1).zero?
222
+
223
+ # @input_limit is a decimal number
224
+ case @input_limit
225
+ when 0...1
226
+ @window = @input_window / @input_limit
227
+ @limit = 1
228
+ when (1..)
229
+ if @input_window >= 2
230
+ @window = @input_window * @input_limit.floor / @input_limit
231
+ @limit = @input_limit.floor
232
+ else
233
+ @window = @input_window * @input_limit.ceil / @input_limit
234
+ @limit = @input_limit.ceil
235
+ end
236
+ else
237
+ raise "invalid limit #{@input_limit}"
238
+ end
239
+ end
240
+
241
+ def validate!
242
+ unless TYPES.include?(@type)
243
+ raise ArgumentError, "invalid type #{@type.inspect}"
244
+ end
245
+
246
+ validate_limit!
247
+ validate_window!
248
+ end
249
+
250
+ def validate_limit!(value = @input_limit)
251
+ unless value.positive?
252
+ raise ArgumentError, "limit must be positive number"
253
+ end
254
+ end
255
+
256
+ def validate_window!(value = @input_window)
257
+ unless value.positive?
258
+ raise ArgumentError, "window must be positive number"
259
+ end
260
+ end
261
+ end
262
+ end
263
+ end
@@ -0,0 +1,20 @@
1
+ require_relative "../window"
2
+
3
+ module Async
4
+ module Limiter
5
+ class Window
6
+ class Continuous < Window
7
+ def initialize(limit = 1, window: 1, parent: nil, lock: true)
8
+ super(
9
+ limit,
10
+ type: :sliding, # type doesn't matter, but sliding is less work
11
+ burstable: false,
12
+ window: window,
13
+ parent: parent,
14
+ lock: lock
15
+ )
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,20 @@
1
+ require_relative "../window"
2
+
3
+ module Async
4
+ module Limiter
5
+ class Window
6
+ class Fixed < Window
7
+ def initialize(limit = 1, window: 1, parent: nil, lock: true)
8
+ super(
9
+ limit,
10
+ type: :fixed,
11
+ burstable: true,
12
+ window: window,
13
+ parent: parent,
14
+ lock: lock
15
+ )
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,20 @@
1
+ require_relative "../window"
2
+
3
+ module Async
4
+ module Limiter
5
+ class Window
6
+ class Sliding < Window
7
+ def initialize(limit = 1, window: 1, parent: nil, lock: true)
8
+ super(
9
+ limit,
10
+ type: :sliding,
11
+ burstable: true,
12
+ window: window,
13
+ parent: parent,
14
+ lock: lock
15
+ )
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: async-limiter
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bruno Sutic
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-10-03 00:00:00.000000000 Z
11
+ date: 2020-10-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: async
@@ -24,6 +24,48 @@ dependencies:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
26
  version: '1.26'
27
+ - !ruby/object:Gem::Dependency
28
+ name: async-rspec
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.14'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.14'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.9'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.9'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rubocop-rspec
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '1.43'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '1.43'
27
69
  - !ruby/object:Gem::Dependency
28
70
  name: standard
29
71
  requirement: !ruby/object:Gem::Requirement
@@ -46,10 +88,13 @@ extra_rdoc_files: []
46
88
  files:
47
89
  - lib/async/limiter.rb
48
90
  - lib/async/limiter/concurrent.rb
49
- - lib/async/limiter/delay.rb
50
- - lib/async/limiter/fixed_window.rb
51
- - lib/async/limiter/sliding_window.rb
91
+ - lib/async/limiter/constants.rb
92
+ - lib/async/limiter/unlimited.rb
52
93
  - lib/async/limiter/version.rb
94
+ - lib/async/limiter/window.rb
95
+ - lib/async/limiter/window/continuous.rb
96
+ - lib/async/limiter/window/fixed.rb
97
+ - lib/async/limiter/window/sliding.rb
53
98
  homepage: https://github.com/bruno-/async-limiter
54
99
  licenses:
55
100
  - MIT
@@ -69,7 +114,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
69
114
  - !ruby/object:Gem::Version
70
115
  version: '0'
71
116
  requirements: []
72
- rubygems_version: 3.1.2
117
+ rubygems_version: 3.2.0.rc.1
73
118
  signing_key:
74
119
  specification_version: 4
75
120
  summary: Async limiters
@@ -1,47 +0,0 @@
1
- require "async/clock"
2
- require_relative "../limiter"
3
-
4
- module Async
5
- class Limiter
6
- # Ensures units are evenly acquired during the sliding time window.
7
- # Example: If limit is 2 you can perform one operation every 500ms. First
8
- # operation at 10:10:10.000, and then another one at 10:10:10.500.
9
- class Throttle < Limiter
10
- attr_reader :window
11
-
12
- def initialize(*args, window: 1, min_limit: 0, **options)
13
- super(*args, min_limit: min_limit, **options)
14
-
15
- @window = window
16
- @last_acquired_time = -1
17
- end
18
-
19
- def blocking?
20
- super && current_delay.positive?
21
- end
22
-
23
- def acquire
24
- super
25
- @last_acquired_time = now
26
- end
27
-
28
- def delay
29
- @window.to_f / @limit
30
- end
31
-
32
- private
33
-
34
- def now
35
- Clock.now
36
- end
37
-
38
- def current_delay
39
- [delay - elapsed_time, 0].max
40
- end
41
-
42
- def elapsed_time
43
- now - @last_acquired_time
44
- end
45
- end
46
- end
47
- end
@@ -1,53 +0,0 @@
1
- require "async/clock"
2
- require_relative "../limiter"
3
-
4
- module Async
5
- class Limiter
6
- # Ensures units are acquired during the time window.
7
- # Example: You can perform N operations at 10:10:10.999, and then can
8
- # perform another N operations at 10:10:11.000.
9
- class FixedWindow < Limiter
10
- attr_reader :window
11
-
12
- def initialize(*args, window: 1, **options)
13
- super(*args, **options)
14
-
15
- @window = window
16
- @acquired_window_indexes = []
17
- end
18
-
19
- def blocking?
20
- super && window_limited?
21
- end
22
-
23
- def acquire
24
- super
25
- @acquired_window_indexes.unshift(window_index)
26
- # keep more entries in case a limit is increased
27
- @acquired_window_indexes = @acquired_window_indexes.first(keep_limit)
28
- end
29
-
30
- private
31
-
32
- def window_limited?
33
- first_index_in_limit_scope == window_index
34
- end
35
-
36
- def first_index_in_limit_scope
37
- @acquired_window_indexes.fetch(@limit - 1) { -1 }
38
- end
39
-
40
- def window_index
41
- (now / @window).floor
42
- end
43
-
44
- def keep_limit
45
- @max_limit.infinite? ? @limit * 10 : @max_limit
46
- end
47
-
48
- def now
49
- Clock.now
50
- end
51
- end
52
- end
53
- end
@@ -1,53 +0,0 @@
1
- require "async/clock"
2
- require_relative "../limiter"
3
-
4
- module Async
5
- class Limiter
6
- # Ensures units are acquired during the sliding time window.
7
- # Example: You can perform N operations at 10:10:10.999 but can't perform
8
- # another N operations until 10:10:11.999.
9
- class SlidingWindow < Limiter
10
- attr_reader :window
11
-
12
- def initialize(*args, window: 1, min_limit: 0, **options)
13
- super(*args, min_limit: min_limit, **options)
14
-
15
- @window = window
16
- @acquired_times = []
17
- end
18
-
19
- def blocking?
20
- super && window_limited?
21
- end
22
-
23
- def acquire
24
- super
25
- @acquired_times.unshift(now)
26
- # keep more entries in case a limit is increased
27
- @acquired_times = @acquired_times.first(keep_limit)
28
- end
29
-
30
- private
31
-
32
- def window_limited?
33
- first_time_in_limit_scope >= window_start_time
34
- end
35
-
36
- def first_time_in_limit_scope
37
- @acquired_times.fetch(@limit - 1) { -1 }
38
- end
39
-
40
- def window_start_time
41
- now - @window
42
- end
43
-
44
- def keep_limit
45
- @max_limit.infinite? ? @limit * 10 : @max_limit
46
- end
47
-
48
- def now
49
- Clock.now
50
- end
51
- end
52
- end
53
- end