Skip to content

Commit fb19d0e

Browse files
bensheldoneregon
authored andcommitted
Add TimerTask.new(interval_type:) option to configure interval calculation
Can be either `:fixed_delay` or `:fixed_rate`, default to `:fixed_delay`
1 parent 18ffea9 commit fb19d0e

File tree

2 files changed

+124
-4
lines changed

2 files changed

+124
-4
lines changed

lib/concurrent-ruby/concurrent/timer_task.rb

Lines changed: 51 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+
DEFAULT_INTERVAL_TYPE = FIXED_DELAY
157182

158183
# Create a new TimerTask with the given task and configuration.
159184
#
@@ -164,6 +189,9 @@ class TimerTask < RubyExecutorService
164189
# @option opts [Boolean] :run_now Whether to run the task immediately
165190
# upon instantiation or to wait until the first # execution_interval
166191
# has passed (default: false)
192+
# @options opts [Symbol] :interval_type method to calculate the interval
193+
# between executions, can be either :fixed_rate or :fixed_delay.
194+
# (default: :fixed_delay)
167195
# @option opts [Executor] executor, default is `global_io_executor`
168196
#
169197
# @!macro deref_options
@@ -243,6 +271,10 @@ def execution_interval=(value)
243271
end
244272
end
245273

274+
# @!attribute [r] interval_type
275+
# @return [Symbol] method to calculate the interval between executions
276+
attr_reader :interval_type
277+
246278
# @!attribute [rw] timeout_interval
247279
# @return [Fixnum] Number of seconds the task can run before it is
248280
# considered to have failed.
@@ -265,10 +297,15 @@ def ns_initialize(opts, &task)
265297
set_deref_options(opts)
266298

267299
self.execution_interval = opts[:execution] || opts[:execution_interval] || EXECUTION_INTERVAL
300+
if opts[:interval_type] && ![FIXED_DELAY, FIXED_RATE].include?(opts[:interval_type])
301+
raise ArgumentError.new('interval_type must be either :fixed_delay or :fixed_rate')
302+
end
268303
if opts[:timeout] || opts[:timeout_interval]
269304
warn 'TimeTask timeouts are now ignored as these were not able to be implemented correctly'
270305
end
306+
271307
@run_now = opts[:now] || opts[:run_now]
308+
@interval_type = opts[:interval_type] || DEFAULT_INTERVAL_TYPE
272309
@task = Concurrent::SafeTaskExecutor.new(task)
273310
@executor = opts[:executor] || Concurrent.global_io_executor
274311
@running = Concurrent::AtomicBoolean.new(false)
@@ -298,16 +335,27 @@ def schedule_next_task(interval = execution_interval)
298335
# @!visibility private
299336
def execute_task(completion)
300337
return nil unless @running.true?
338+
start_time = Concurrent.monotonic_time
301339
_success, value, reason = @task.execute(self)
302340
if completion.try?
303341
self.value = value
304-
schedule_next_task
342+
schedule_next_task(calculate_next_interval(start_time))
305343
time = Time.now
306344
observers.notify_observers do
307345
[time, self.value, reason]
308346
end
309347
end
310348
nil
311349
end
350+
351+
# @!visibility private
352+
def calculate_next_interval(start_time)
353+
if @interval_type == FIXED_RATE
354+
run_time = Concurrent.monotonic_time - start_time
355+
[execution_interval - run_time, 0].max
356+
else # FIXED_DELAY
357+
execution_interval
358+
end
359+
end
312360
end
313361
end

spec/concurrent/timer_task_spec.rb

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,21 @@ def trigger_observable(observable)
8383
expect(subject.execution_interval).to eq 5
8484
end
8585

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
86101
end
87102

88103
context '#kill' do
@@ -113,7 +128,6 @@ def trigger_observable(observable)
113128
end
114129

115130
specify '#execution_interval is writeable' do
116-
117131
latch = CountDownLatch.new(1)
118132
subject = TimerTask.new(timeout_interval: 1,
119133
execution_interval: 1,
@@ -133,6 +147,28 @@ def trigger_observable(observable)
133147
subject.kill
134148
end
135149

150+
it 'raises on invalid interval_type' do
151+
expect {
152+
fixed_delay = TimerTask.new(interval_type: TimerTask::FIXED_DELAY,
153+
execution_interval: 0.1,
154+
run_now: true) { nil }
155+
fixed_delay.kill
156+
}.not_to raise_error
157+
158+
expect {
159+
fixed_rate = TimerTask.new(interval_type: TimerTask::FIXED_RATE,
160+
execution_interval: 0.1,
161+
run_now: true) { nil }
162+
fixed_rate.kill
163+
}.not_to raise_error
164+
165+
expect {
166+
TimerTask.new(interval_type: :unknown,
167+
execution_interval: 0.1,
168+
run_now: true) { nil }
169+
}.to raise_error(ArgumentError)
170+
end
171+
136172
specify '#timeout_interval being written produces a warning' do
137173
subject = TimerTask.new(timeout_interval: 1,
138174
execution_interval: 0.1,
@@ -209,6 +245,42 @@ def trigger_observable(observable)
209245

210246
expect(executor).to have_received(:post)
211247
end
248+
249+
it 'uses a fixed delay when set' do
250+
finished = []
251+
latch = CountDownLatch.new(2)
252+
subject = TimerTask.new(interval_type: TimerTask::FIXED_DELAY,
253+
execution_interval: 0.1,
254+
run_now: true) do |task|
255+
sleep(0.2)
256+
finished << Concurrent.monotonic_time
257+
latch.count_down
258+
end
259+
subject.execute
260+
latch.wait(1)
261+
subject.kill
262+
263+
expect(latch.count).to eq(0)
264+
expect(finished[1] - finished[0]).to be >= 0.3
265+
end
266+
267+
it 'uses a fixed rate when set' do
268+
finished = []
269+
latch = CountDownLatch.new(2)
270+
subject = TimerTask.new(interval_type: TimerTask::FIXED_RATE,
271+
execution_interval: 0.1,
272+
run_now: true) do |task|
273+
sleep(0.2)
274+
finished << Concurrent.monotonic_time
275+
latch.count_down
276+
end
277+
subject.execute
278+
latch.wait(1)
279+
subject.kill
280+
281+
expect(latch.count).to eq(0)
282+
expect(finished[1] - finished[0]).to be < 0.3
283+
end
212284
end
213285

214286
context 'observation' do

0 commit comments

Comments
 (0)