async-limiter 0.0.1 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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