ztimer 0.6.0 → 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
- SHA1:
3
- metadata.gz: ba1fe46353cca7319d61150f6fe52622d3668db2
4
- data.tar.gz: ac6dc6b45e0001d39e63a6ead7950b5db75ac7d0
2
+ SHA256:
3
+ metadata.gz: 328b728b6ba74021474c30ef9093a1bca987a368b2bd211d5545abc888447ef2
4
+ data.tar.gz: df0ff61ae1bbf3901a1ddc9fa0ac3416dce79eeb1c4e05f1d9a3f61abce722c0
5
5
  SHA512:
6
- metadata.gz: 1eac7a0992c9d806a76c35cc31805bb716e7ca8b39e4b15b0609ac8c9415464c816b6b57a74821745b213ad57a9e21843f641b16663c19cf3e46d267b6216ce9
7
- data.tar.gz: f81c9081bad5354bb6e0f056596b9c8db7ce5878d6ff6d1da5e82536c218e143c59ef60389bda4b27bed205d09881c35e6c7dccf28f8f15d7395cf487142fe00
6
+ metadata.gz: d482e9792d40f7083258a66cac5b1857624d02dbc6f4297a3ea608c48f8f50908dc3fe429e0666b54bd67b3b3e9b846f7869aa8ea253e980167bd2502cfd4626
7
+ data.tar.gz: d5b3ce5d4189739c8a32851383775afe84bd238d324dc26806e122f2b19fd9a4dd65509c9b04ded9aae399b3eeff19bb3a01377155408b680ed533261401b1a3
data/README.md CHANGED
@@ -58,6 +58,20 @@ my_timer = Ztimer.new(concurrency: 5) # create a new Ztimer instance
58
58
  end
59
59
  ```
60
60
 
61
+ | Method | Description |
62
+ |--------|-------------|
63
+ | `async(&block)` | Execute the block asynchronously. |
64
+ | `after(milliseconds, &block)` | Execute the block after the specified amount of milliseconds. |
65
+ | `at(datetime, &block)` | Execute the block at the specified timestamp. |
66
+ | `every(milliseconds, start_at: nil, &block)` | Execute the block at the specified interval of milliseconds. A custom `:start_at` param could be provided to specify an offset timestamp. |
67
+ | `secondly(seconds, offset: 0, &block)` | Executes the block every N seconds. An `:offset` of seconds could be specified to shift the begining of the time slot. By default the block will be exected at the begining of the time slot. Example: `secondly(5)` will run at second `0`, `5`, `10`, `15`, etc. |
68
+ | `minutely(minutes, offset: 0, &block)` | Executes the block every N minutes. An `:offset` of minutes could be specified to shift the begining of the time slot. By default the block will be exected at the begining of the time slot. Example: `minutely(5)` will run at minute `0`, `5`, `10`, `15`, etc. |
69
+ | `hourly(hours, offset: 0, &block)` | Executes the block every N hours. An `:offset` of hours could be specified to shift the begining of the time slot. By default the block will be exected at the begining of the time slot. Example: `hourly(5)` will run at hour `0`, `5`, `10`, `15`, etc. |
70
+ | `daily(days, offset: 0, &block)` | Executes the block every N days. An `:offset` of days could be specified to shift the begining of the time slot. By default the block will be exected at the begining of the time slot. Example: `daily(5)` will run on day `0`, `5`, `10`, `15`, etc. |
71
+ | `day_of_week(day, &block)` | Execute the block only on the specified day of week. Valid days are: `"sun", "mon", "tue", "thu", "wen", "fri", "sat"`. |
72
+ | `days_of_week(days, &block)` | Execute the block on the specified days of week. |
73
+
74
+
61
75
  By default **Ztimer** will run at maximum 20 jobs concurrently, so that if you have 100 jobs to be
62
76
  executed at the same time, at most 20 of them will run concurrently. This is necessary in order to prevent uncontrolled threads spawn when many jobs have to be run at the same time.
63
77
 
data/lib/ztimer/slot.rb CHANGED
@@ -1,10 +1,12 @@
1
+ # frozen_string_literal: true
1
2
 
2
3
  class Ztimer
4
+ # Implements a slot, which represents a block of code to be executed at specified time slot.
3
5
  class Slot
4
6
  attr_reader :enqueued_at, :expires_at, :recurrency, :callback
5
7
  attr_accessor :started_at, :executed_at
6
8
 
7
- def initialize(enqueued_at, expires_at,recurrency = -1, &callback)
9
+ def initialize(enqueued_at, expires_at, recurrency = -1, &callback)
8
10
  @enqueued_at = enqueued_at
9
11
  @expires_at = expires_at
10
12
  @recurrency = recurrency
@@ -15,17 +17,15 @@ class Ztimer
15
17
  end
16
18
 
17
19
  def recurrent?
18
- return @recurrency > 0
20
+ @recurrency.positive?
19
21
  end
20
22
 
21
23
  def reset!
22
- if recurrent?
23
- @expires_at += recurrency
24
- end
24
+ @expires_at += recurrency if recurrent?
25
25
  end
26
26
 
27
27
  def canceled?
28
- return @canceled
28
+ @canceled
29
29
  end
30
30
 
31
31
  def cancel!
@@ -33,7 +33,7 @@ class Ztimer
33
33
  end
34
34
 
35
35
  def <=>(other)
36
- return @expires_at <=> other.expires_at
36
+ @expires_at <=> other.expires_at
37
37
  end
38
38
  end
39
39
  end
@@ -1,95 +1,92 @@
1
+ # frozen_string_literal: true
1
2
 
2
3
  class Ztimer
4
+ # Implements a performant sorted store for time slots, which uses binary search to optimize
5
+ # new items insertion and items retrievement.
3
6
  class SortedStore
4
-
5
7
  def initialize
6
8
  @store = []
7
9
  end
8
10
 
9
11
  def <<(value)
10
12
  @store.insert(position_for(value), value)
11
- return self
13
+
14
+ self
12
15
  end
13
16
 
14
17
  def delete(value)
15
18
  index = index_of(value)
16
- if index
17
- @store.delete_at(index)
18
- else
19
- return nil
20
- end
19
+
20
+ index.nil? ? nil : @store.delete_at(index)
21
21
  end
22
22
 
23
23
  def [](index)
24
- return @store[index]
24
+ @store[index]
25
25
  end
26
26
 
27
27
  def first
28
- return @store.first
28
+ @store.first
29
29
  end
30
30
 
31
31
  def last
32
- return @store.last
32
+ @store.last
33
33
  end
34
34
 
35
35
  def shift
36
- return @store.shift
36
+ @store.shift
37
37
  end
38
38
 
39
39
  def pop
40
- return @store.pop
40
+ @store.pop
41
41
  end
42
42
 
43
43
  def index_of(value, start = 0, stop = [@store.count - 1, 0].max)
44
- if start > stop
45
- return nil
46
- elsif start == stop
47
- return value == @store[start] ? start : nil
48
- else
49
- position = ((stop + start)/ 2).to_i
50
- case value <=> @store[position]
51
- when -1 then return index_of(value, start, position)
52
- when 0 then return position
53
- when 1 then return index_of(value, position + 1, stop)
54
- end
44
+ return nil if start > stop
45
+ return value == @store[start] ? start : nil if start == stop
46
+
47
+ position = ((stop + start) / 2).to_i
48
+
49
+ case value <=> @store[position]
50
+ when -1 then index_of(value, start, position)
51
+ when 0 then position
52
+ when 1 then index_of(value, position + 1, stop)
55
53
  end
56
54
  end
57
55
 
58
56
  def count
59
- return @store.count
57
+ @store.count
60
58
  end
61
59
 
62
60
  def size
63
- return @store.size
61
+ @store.size
64
62
  end
65
63
 
66
64
  def empty?
67
- return @store.empty?
65
+ @store.empty?
68
66
  end
69
67
 
70
68
  def clear
71
- return @store.clear
69
+ @store.clear
72
70
  end
73
71
 
74
72
  def to_a
75
- return @store.dup
73
+ @store.dup
76
74
  end
77
75
 
78
-
79
76
  protected
80
77
 
81
78
  def position_for(item, start = 0, stop = [@store.count - 1, 0].max)
82
- if start > stop
83
- raise "Invalid range (#{start}, #{stop})"
84
- elsif start == stop
79
+ raise "Invalid range (#{start}, #{stop})" if start > stop
80
+
81
+ if start == stop
85
82
  element = @store[start]
86
- return element.nil? || ((item <=> element) <= 0) ? start : start + 1
83
+ element.nil? || ((item <=> element) <= 0) ? start : start + 1
87
84
  else
88
- position = ((stop + start)/ 2).to_i
85
+ position = ((stop + start) / 2).to_i
89
86
  case item <=> @store[position]
90
- when -1 then return position_for(item, start, position)
91
- when 0 then return position
92
- when 1 then return position_for(item, position + 1, stop)
87
+ when -1 then position_for(item, start, position)
88
+ when 0 then position
89
+ when 1 then position_for(item, position + 1, stop)
93
90
  end
94
91
  end
95
92
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  class Ztimer
2
- VERSION = "0.6.0"
4
+ VERSION = '1.0.0'
3
5
  end
@@ -1,7 +1,9 @@
1
+ # frozen_string_literal: true
1
2
 
2
3
  class Ztimer
4
+ # Implements a watcher which allows to enqueue Ztimer::Slot items, that will be executed
5
+ # as soon as the time of Ztimer::Slot is reached.
3
6
  class Watcher
4
-
5
7
  def initialize(&callback)
6
8
  @thread = nil
7
9
  @slots = Ztimer::SortedStore.new
@@ -10,17 +12,15 @@ class Ztimer
10
12
  @mutex = Mutex.new
11
13
  end
12
14
 
13
- def << (slot)
15
+ def <<(slot)
14
16
  @mutex.synchronize do
15
17
  @slots << slot
16
- if @slots.first == slot
17
- run
18
- end
18
+ run if @slots.first == slot
19
19
  end
20
20
  end
21
21
 
22
22
  def jobs
23
- return @slots.size
23
+ @slots.size
24
24
  end
25
25
 
26
26
  protected
@@ -37,10 +37,11 @@ class Ztimer
37
37
  def start
38
38
  @lock.synchronize do
39
39
  return if @thread
40
+
40
41
  @thread = Thread.new do
41
42
  loop do
42
43
  begin
43
- delay = get_delay
44
+ delay = calculate_delay
44
45
  if delay.nil?
45
46
  Thread.stop
46
47
  next
@@ -48,10 +49,10 @@ class Ztimer
48
49
 
49
50
  select(nil, nil, nil, delay / 1_000_000.to_f) if delay > 1 # 1 microsecond of cranularity
50
51
 
51
- while get_first_expired do
52
+ while fetch_first_expired
52
53
  end
53
- rescue => e
54
- puts e.inspect + "\n" + e.backtrace.join("\n")
54
+ rescue StandardError => e
55
+ puts "#{e.inspect}\n#{e.backtrace.join("\n")}"
55
56
  end
56
57
  end
57
58
  end
@@ -59,11 +60,11 @@ class Ztimer
59
60
  end
60
61
  end
61
62
 
62
- def get_delay
63
- return @mutex.synchronize { @slots.empty? ? nil : @slots.first.expires_at - utc_microseconds }
63
+ def calculate_delay
64
+ @mutex.synchronize { @slots.empty? ? nil : @slots.first.expires_at - utc_microseconds }
64
65
  end
65
66
 
66
- def get_first_expired
67
+ def fetch_first_expired
67
68
  @mutex.synchronize do
68
69
  slot = @slots.first
69
70
  if slot && (slot.expires_at < utc_microseconds)
@@ -89,7 +90,7 @@ class Ztimer
89
90
  end
90
91
 
91
92
  def utc_microseconds
92
- return Time.now.to_f * 1_000_000
93
+ Time.now.to_f * 1_000_000
93
94
  end
94
95
  end
95
96
  end
data/lib/ztimer.rb CHANGED
@@ -1,8 +1,11 @@
1
- require "ztimer/version"
2
- require "ztimer/slot"
3
- require "ztimer/sorted_store"
4
- require "ztimer/watcher"
1
+ # frozen_string_literal: true
5
2
 
3
+ require 'ztimer/version'
4
+ require 'ztimer/slot'
5
+ require 'ztimer/sorted_store'
6
+ require 'ztimer/watcher'
7
+
8
+ # Implements a timer which allows to execute a block with a delay, recurrently or asynchronously.
6
9
  class Ztimer
7
10
  @default_instance = nil
8
11
 
@@ -10,7 +13,7 @@ class Ztimer
10
13
 
11
14
  def initialize(concurrency: 20)
12
15
  @concurrency = concurrency
13
- @watcher = Ztimer::Watcher.new(){|slot| execute(slot) }
16
+ @watcher = Ztimer::Watcher.new { |slot| execute(slot) }
14
17
  @workers_lock = Mutex.new
15
18
  @count_lock = Mutex.new
16
19
  @queue = Queue.new
@@ -18,6 +21,7 @@ class Ztimer
18
21
  @count = 0
19
22
  end
20
23
 
24
+ # Execute the code block asyncrhonously right now
21
25
  def async(&callback)
22
26
  enqueued_at = utc_microseconds
23
27
  slot = Slot.new(enqueued_at, enqueued_at, -1, &callback)
@@ -25,9 +29,10 @@ class Ztimer
25
29
  incr_counter!
26
30
  execute(slot)
27
31
 
28
- return slot
32
+ slot
29
33
  end
30
34
 
35
+ # Execute the code block after the specified delay
31
36
  def after(milliseconds, &callback)
32
37
  enqueued_at = utc_microseconds
33
38
  expires_at = enqueued_at + milliseconds * 1000
@@ -35,39 +40,104 @@ class Ztimer
35
40
 
36
41
  add(slot)
37
42
 
38
- return slot
43
+ slot
39
44
  end
40
45
 
41
- def every(milliseconds, &callback)
42
- enqueued_at = utc_microseconds
43
- expires_at = enqueued_at + milliseconds * 1000
44
- slot = Slot.new(enqueued_at, expires_at, milliseconds * 1000, &callback)
46
+ # Execute the code block at a specific datetime
47
+ def at(datetime, &callback)
48
+ enqueued_at = datetime.to_f * 1_000_000
49
+
50
+ slot = Slot.new(enqueued_at, enqueued_at, -1, &callback)
51
+ add(slot)
52
+
53
+ slot
54
+ end
55
+
56
+ # Execute the code block every N milliseconds.
57
+ # When :start_at is specified, the first execution will start at specified date/time
58
+ def every(milliseconds, start_at: nil, &callback)
59
+ enqueued_at = start_at ? start_at.to_f * 1_000_000 : utc_microseconds
60
+ expires_at = enqueued_at + milliseconds * 1000
61
+ slot = Slot.new(enqueued_at, expires_at, milliseconds * 1000, &callback)
45
62
 
46
63
  add(slot)
47
64
 
48
- return slot
65
+ slot
66
+ end
67
+
68
+ # Run ztimer every N seconds, starting with the nearest time slot (ex. secondly(5)
69
+ # will run at second 0, 5, 10, 15, etc.)
70
+ def secondly(seconds, offset: 0, &callback)
71
+ start_time = utc_microseconds
72
+ milliseconds = (seconds.to_f * 1000).to_i
73
+ enqueued_at = start_time - (start_time % (milliseconds * 1000)) + offset * 1_000_000
74
+ expires_at = enqueued_at + milliseconds * 1000
75
+
76
+ slot = Slot.new(enqueued_at, expires_at, milliseconds * 1000, &callback)
77
+ add(slot)
78
+
79
+ slot
80
+ end
81
+
82
+ # Run ztimer every N minutes, starting at the nearest time slot (ex. minutely(2) will run at minute 0, 2, 4, 6, etc.)
83
+ def minutely(minutes, offset: 0, &callback)
84
+ secondly(minutes.to_f * 60, offset: offset.to_f * 60, &callback)
85
+ end
86
+
87
+ # Run ztimer every N hours, starting at the nearest time slot (ex. hourly(2) will run at hour 0, 2, 4, 6, etc.)
88
+ def hourly(hours, offset: 0, &callback)
89
+ minutely(hours.to_f * 60, offset: offset.to_f * 60, &callback)
90
+ end
91
+
92
+ def daily(days, offset: 0, &callback)
93
+ raise ArgumentError, "Days number should be > 0: #{days.inspect}" if days.to_f <= 0
94
+
95
+ hourly(days.to_f * 24, offset: offset.to_f * 24, &callback)
96
+ end
97
+
98
+ def day_of_week(day, &callback)
99
+ days = %w[sun mon tue thu wen fri sat]
100
+ current_day = Time.now.wday
101
+
102
+ index = day.to_i
103
+ if day.is_a?(String)
104
+ # Find day number by day name
105
+ index = days.index { |day_name| day.strip.downcase == day_name }
106
+ raise ArgumentError, "Invalid week day: #{day.inspect}" if index.nil?
107
+ elsif index.negative? || index > 6
108
+ raise ArgumentError, "Invalid week day: #{day.inspect}"
109
+ end
110
+
111
+ offset = 0
112
+ offset = (current_day > index ? index - current_day : current_day - index) if current_day != index
113
+
114
+ daily(7, offset: offset, &callback)
115
+ end
116
+
117
+ def days_of_week(*args, &callback)
118
+ args.map { |day| day_of_week(day, &callback) }
49
119
  end
50
120
 
51
121
  def jobs_count
52
- return @watcher.jobs
122
+ @watcher.jobs
53
123
  end
54
124
 
55
125
  def concurrency=(new_value)
56
- raise ArgumentError.new("Invalid concurrency value: #{new_value}") unless new_value.is_a?(Fixnum) && new_value >= 1
126
+ value_is_integer = new_value.is_a?(Integer)
127
+ raise ArgumentError, "Invalid concurrency value: #{new_value}" unless value_is_integer && new_value >= 1
128
+
57
129
  @concurrency = new_value
58
130
  end
59
131
 
60
-
61
132
  def stats
62
133
  {
63
- running: @running,
134
+ running: @running,
64
135
  scheduled: @watcher.jobs,
65
136
  executing: @queue.size,
66
- total: @count
137
+ total: @count
67
138
  }
68
139
  end
69
140
 
70
-
71
141
  def self.method_missing(name, *args, &block)
72
142
  @default_instance ||= Ztimer.new(concurrency: 20)
73
143
  @default_instance.send(name, *args, &block)
@@ -108,12 +178,13 @@ class Ztimer
108
178
  begin
109
179
  current_slot.executed_at = utc_microseconds
110
180
  current_slot.callback.call(current_slot) unless current_slot.callback.nil? || current_slot.canceled?
111
- rescue => e
112
- STDERR.puts e.inspect + (e.backtrace ? "\n" + e.backtrace.join("\n") : "")
181
+ rescue StandardError => e
182
+ backtrace = e.backtrace ? "\n#{e.backtrace.join("\n")}" : ''
183
+ warn e.inspect + backtrace
113
184
  end
114
185
  end
115
186
  rescue ThreadError
116
- puts "queue is empty"
187
+ puts 'queue is empty'
117
188
  end
118
189
  @workers_lock.synchronize { @running -= 1 }
119
190
  end
@@ -121,6 +192,6 @@ class Ztimer
121
192
  end
122
193
 
123
194
  def utc_microseconds
124
- return Time.now.to_f * 1_000_000
195
+ Time.now.to_f * 1_000_000
125
196
  end
126
197
  end
data/ztimer.gemspec CHANGED
@@ -1,25 +1,28 @@
1
- # coding: utf-8
2
- lib = File.expand_path('../lib', __FILE__)
1
+ # frozen_string_literal: true
2
+
3
+ lib = File.expand_path('lib', __dir__)
3
4
  $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
5
  require 'ztimer/version'
5
6
 
6
7
  Gem::Specification.new do |spec|
7
- spec.name = "ztimer"
8
+ spec.name = 'ztimer'
8
9
  spec.version = Ztimer::VERSION
9
- spec.authors = ["Groza Sergiu"]
10
- spec.email = ["serioja90@gmail.com"]
10
+ spec.authors = ['Groza Sergiu']
11
+ spec.email = ['serioja90@gmail.com']
11
12
 
12
- spec.summary = %q{An asyncrhonous timer}
13
- spec.description = %q{Ruby asyncrhonous timer that allows you to enqueue tasks to be executed asyncrhonously after a delay}
14
- spec.homepage = "https://github.com/serioja90/ztimer"
15
- spec.license = "MIT"
13
+ spec.summary = %(An asyncrhonous timer)
14
+ spec.description = %(Ruby asyncrhonous timer that allows you to enqueue tasks to be executed asyncrhonously after a delay)
15
+ spec.homepage = 'https://github.com/serioja90/ztimer'
16
+ spec.license = 'MIT'
16
17
 
17
18
  spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
18
- spec.bindir = "exe"
19
+ spec.bindir = 'exe'
19
20
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
20
- spec.require_paths = ["lib"]
21
+ spec.require_paths = ['lib']
22
+
23
+ spec.required_ruby_version = '>= 2.5'
21
24
 
22
- spec.add_development_dependency "bundler", "~> 1.11"
23
- spec.add_development_dependency "rake", "~> 10.0"
24
- spec.add_development_dependency "rspec", "~> 3.0"
25
+ spec.add_development_dependency 'bundler', '~>2.2', '>= 2.2.33'
26
+ spec.add_development_dependency 'rake', '~>12.3', '>= 12.3.3'
27
+ spec.add_development_dependency 'rspec', '~> 3.0'
25
28
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ztimer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Groza Sergiu
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2017-02-20 00:00:00.000000000 Z
11
+ date: 2022-04-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -16,28 +16,40 @@ dependencies:
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: '1.11'
19
+ version: '2.2'
20
+ - - ">="
21
+ - !ruby/object:Gem::Version
22
+ version: 2.2.33
20
23
  type: :development
21
24
  prerelease: false
22
25
  version_requirements: !ruby/object:Gem::Requirement
23
26
  requirements:
24
27
  - - "~>"
25
28
  - !ruby/object:Gem::Version
26
- version: '1.11'
29
+ version: '2.2'
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 2.2.33
27
33
  - !ruby/object:Gem::Dependency
28
34
  name: rake
29
35
  requirement: !ruby/object:Gem::Requirement
30
36
  requirements:
31
37
  - - "~>"
32
38
  - !ruby/object:Gem::Version
33
- version: '10.0'
39
+ version: '12.3'
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: 12.3.3
34
43
  type: :development
35
44
  prerelease: false
36
45
  version_requirements: !ruby/object:Gem::Requirement
37
46
  requirements:
38
47
  - - "~>"
39
48
  - !ruby/object:Gem::Version
40
- version: '10.0'
49
+ version: '12.3'
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: 12.3.3
41
53
  - !ruby/object:Gem::Dependency
42
54
  name: rspec
43
55
  requirement: !ruby/object:Gem::Requirement
@@ -87,15 +99,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
87
99
  requirements:
88
100
  - - ">="
89
101
  - !ruby/object:Gem::Version
90
- version: '0'
102
+ version: '2.5'
91
103
  required_rubygems_version: !ruby/object:Gem::Requirement
92
104
  requirements:
93
105
  - - ">="
94
106
  - !ruby/object:Gem::Version
95
107
  version: '0'
96
108
  requirements: []
97
- rubyforge_project:
98
- rubygems_version: 2.4.8
109
+ rubygems_version: 3.0.8
99
110
  signing_key:
100
111
  specification_version: 4
101
112
  summary: An asyncrhonous timer