Skip to content

Commit 588de9a

Browse files
committed
Add TimerTask#interval_type option to configure interval calculation
Can be either `:fixed_delay` or `:fixed_rate`, default to `:fixed_delay`
1 parent 9f40827 commit 588de9a

File tree

2 files changed

+121
-4
lines changed

2 files changed

+121
-4
lines changed

lib/concurrent-ruby/concurrent/timer_task.rb

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,17 @@ module Concurrent
3232
# be tested separately then passed to the `TimerTask` for scheduling and
3333
# running.
3434
#
35+
# A `TimerTask` supports two different types of interval calculations.
36+
# A fixed delay will always wait the same amount of time between the
37+
# completion of one task and the start of the next. A fixed rate will
38+
# attempt to maintain a constant rate of execution regardless of the
39+
# duration of the task. For example, if a fixed rate task is scheduled
40+
# to run every 60 seconds but the task itself takes 10 seconds to
41+
# complete, the next task will be scheduled to run 50 seconds after
42+
# the start of the previous task. If the task takes 70 seconds to
43+
# complete, the next task will be start immediately after the previous
44+
# task completes. Tasks will not be executed concurrently.
45+
#
3546
# In some cases it may be necessary for a `TimerTask` to affect its own
3647
# execution cycle. To facilitate this, a reference to the TimerTask instance
3748
# is passed as an argument to the provided block every time the task is
@@ -74,6 +85,12 @@ module Concurrent
7485
#
7586
# #=> 'Boom!'
7687
#
88+
# @example Configuring `:interval_type` with either :fixed_delay or :fixed_rate, default is :fixed_delay
89+
# task = Concurrent::TimerTask.new(execution_interval: 5, interval_type: :fixed_rate) do
90+
# puts 'Boom!'
91+
# end
92+
# task.interval_type #=> :fixed_rate
93+
#
7794
# @example Last `#value` and `Dereferenceable` mixin
7895
# task = Concurrent::TimerTask.new(
7996
# dup_on_deref: true,
@@ -152,8 +169,16 @@ class TimerTask < RubyExecutorService
152169
# Default `:execution_interval` in seconds.
153170
EXECUTION_INTERVAL = 60
154171

155-
# Default `:timeout_interval` in seconds.
156-
TIMEOUT_INTERVAL = 30
172+
# Maintain the interval between the end of one execution and the start of the next execution.
173+
FIXED_DELAY = :fixed_delay
174+
175+
# Maintain the interval between the start of one execution and the start of the next.
176+
# If execution time exceeds the interval, the next execution will start immediately
177+
# after the previous execution finishes. Executions will not run concurrently.
178+
FIXED_RATE = :fixed_rate
179+
180+
# Default `:interval_type`
181+
INTERVAL_TYPE = FIXED_DELAY
157182

158183
# Create a new TimerTask with the given task and configuration.
159184
#
@@ -242,6 +267,24 @@ def execution_interval=(value)
242267
end
243268
end
244269

270+
# @!attribute [rw] interval_type
271+
# @return [Symbol] method to calculate the interval between executions, can be either
272+
# :fixed_rate or :fixed_delay; default to :fixed_delay.
273+
def interval_type
274+
synchronize { @interval_type }
275+
end
276+
277+
# @!attribute [rw] interval_type
278+
# @return [Symbol] method to calculate the interval between executions, can be either
279+
# :fixed_rate or :fixed_delay; default to :fixed_delay.
280+
def interval_type=(value)
281+
if [FIXED_DELAY, FIXED_RATE].include?(value)
282+
synchronize { @interval_type = value }
283+
else
284+
raise ArgumentError.new('must be either :fixed_delay or :fixed_rate')
285+
end
286+
end
287+
245288
# @!attribute [rw] timeout_interval
246289
# @return [Fixnum] Number of seconds the task can run before it is
247290
# considered to have failed.
@@ -264,6 +307,7 @@ def ns_initialize(opts, &task)
264307
set_deref_options(opts)
265308

266309
self.execution_interval = opts[:execution] || opts[:execution_interval] || EXECUTION_INTERVAL
310+
self.interval_type = opts[:interval_type] || INTERVAL_TYPE
267311
if opts[:timeout] || opts[:timeout_interval]
268312
warn 'TimeTask timeouts are now ignored as these were not able to be implemented correctly'
269313
end
@@ -296,10 +340,12 @@ def schedule_next_task(interval = execution_interval)
296340
# @!visibility private
297341
def execute_task(completion)
298342
return nil unless @running.true?
343+
start = Concurrent.monotonic_time
299344
_success, value, reason = @executor.execute(self)
300345
if completion.try?
301346
self.value = value
302-
schedule_next_task
347+
interval = interval_type == FIXED_DELAY ? execution_interval : [execution_interval - (Concurrent.monotonic_time - start), 0].max
348+
schedule_next_task(interval)
303349
time = Time.now
304350
observers.notify_observers do
305351
[time, self.value, reason]

spec/concurrent/timer_task_spec.rb

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,22 @@ def trigger_observable(observable)
8282
subject = TimerTask.new(execution_interval: 5) { nil }
8383
expect(subject.execution_interval).to eq 5
8484
end
85+
86+
it 'raises an exception if :interval_type is not a valid value' do
87+
expect {
88+
Concurrent::TimerTask.new(interval_type: :cat) { nil }
89+
}.to raise_error(ArgumentError)
90+
end
91+
92+
it 'uses the default :interval_type when no type is given' do
93+
subject = TimerTask.new { nil }
94+
expect(subject.interval_type).to eq TimerTask::FIXED_DELAY
95+
end
96+
97+
it 'uses the given interval type' do
98+
subject = TimerTask.new(interval_type: TimerTask::FIXED_RATE) { nil }
99+
expect(subject.interval_type).to eq TimerTask::FIXED_RATE
100+
end
85101
end
86102

87103
context '#kill' do
@@ -112,7 +128,6 @@ def trigger_observable(observable)
112128
end
113129

114130
specify '#execution_interval is writeable' do
115-
116131
latch = CountDownLatch.new(1)
117132
subject = TimerTask.new(timeout_interval: 1,
118133
execution_interval: 1,
@@ -132,6 +147,26 @@ def trigger_observable(observable)
132147
subject.kill
133148
end
134149

150+
specify '#interval_type is writeable' do
151+
latch = CountDownLatch.new(1)
152+
subject = TimerTask.new(interval_type: TimerTask::FIXED_DELAY,
153+
execution_interval: 0.1,
154+
run_now: true) do |task|
155+
task.interval_type = TimerTask::FIXED_DELAY
156+
latch.count_down
157+
end
158+
159+
expect(subject.interval_type).to eq(TimerTask::FIXED_DELAY)
160+
subject.interval_type = TimerTask::FIXED_RATE
161+
expect(subject.interval_type).to eq(TimerTask::FIXED_RATE)
162+
163+
subject.execute
164+
latch.wait(0.1)
165+
166+
expect(subject.interval_type).to eq(TimerTask::FIXED_DELAY)
167+
subject.kill
168+
end
169+
135170
specify '#timeout_interval being written produces a warning' do
136171
subject = TimerTask.new(timeout_interval: 1,
137172
execution_interval: 0.1,
@@ -181,6 +216,42 @@ def trigger_observable(observable)
181216
expect(latch.count).to eq(0)
182217
subject.kill
183218
end
219+
220+
it 'uses a fixed delay when set' do
221+
finished = []
222+
latch = CountDownLatch.new(2)
223+
subject = TimerTask.new(interval_type: TimerTask::FIXED_DELAY,
224+
execution_interval: 0.1,
225+
run_now: true) do |task|
226+
sleep(0.2)
227+
finished << Concurrent.monotonic_time
228+
latch.count_down
229+
end
230+
subject.execute
231+
latch.wait(1)
232+
subject.kill
233+
234+
expect(latch.count).to eq(0)
235+
expect(finished[1] - finished[0]).to be >= 0.3
236+
end
237+
238+
it 'uses a fixed rate when set' do
239+
finished = []
240+
latch = CountDownLatch.new(2)
241+
subject = TimerTask.new(interval_type: TimerTask::FIXED_RATE,
242+
execution_interval: 0.1,
243+
run_now: true) do |task|
244+
sleep(0.2)
245+
finished << Concurrent.monotonic_time
246+
latch.count_down
247+
end
248+
subject.execute
249+
latch.wait(1)
250+
subject.kill
251+
252+
expect(latch.count).to eq(0)
253+
expect(finished[1] - finished[0]).to be < 0.3
254+
end
184255
end
185256

186257
context 'observation' do

0 commit comments

Comments
 (0)