async-background 1.0.1 → 1.1.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.
@@ -1,101 +1,167 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'socket'
4
- require 'async/notification'
5
4
  require 'fileutils'
5
+ require_relative '../runtime'
6
6
 
7
7
  module Async
8
8
  module Background
9
9
  module Queue
10
10
  class SocketWaker
11
+ CLOSE_GRACE = 2
12
+
11
13
  attr_reader :path
12
14
 
13
15
  def initialize(path)
14
16
  @path = path
15
17
  @server = nil
16
- @notification = ::Async::Notification.new
18
+ @notification = Runtime::Notification.new
17
19
  @running = false
18
20
  @accept_task = nil
21
+ @clients = Runtime::TaskGroup.new
22
+ @sockets = {}
19
23
  end
20
24
 
21
25
  def open!
22
- cleanup_stale_socket
23
26
  ensure_directory
27
+ cleanup_stale_socket
24
28
  @server = UNIXServer.new(@path)
25
- File.chmod(0600, @path)
26
29
  @running = true
30
+ self
27
31
  rescue Errno::EADDRINUSE
28
32
  raise "Socket #{@path} is already in use by another process"
29
33
  end
30
34
 
31
- def start_accept_loop(parent_task)
32
- @accept_task = parent_task.async do |task|
33
- while @running
34
- begin
35
- client = @server.accept_nonblock
36
- handle_client(task, client)
37
- rescue IO::WaitReadable
38
- @server.wait_readable
39
- rescue Errno::EBADF, IOError
40
- break
41
- rescue => e
42
- Console.logger.error(self) { "SocketWaker accept error: #{e.class} #{e.message}" }
43
- end
44
- end
45
- rescue => e
46
- Console.logger.error(self) { "SocketWaker loop crashed: #{e.class} #{e.message}\n#{e.backtrace.join("\n")}" }
47
- ensure
48
- @accept_task = nil
49
- end
35
+ def start_accept_loop(_parent_task = nil)
36
+ @accept_task = Runtime.spawn(name: 'socket-waker-accept') { accept_loop }
50
37
  end
51
38
 
52
39
  def wait(timeout: nil)
53
- if timeout
54
- ::Async::Task.current.with_timeout(timeout) { @notification.wait }
55
- else
56
- @notification.wait
57
- end
58
- rescue ::Async::TimeoutError
59
- # Timeout is normal - listener will fall back to polling
40
+ @notification.wait(timeout)
60
41
  end
61
42
 
62
43
  def signal
63
- @notification.signal
44
+ @notification.signal_all
45
+ true
64
46
  end
65
47
 
66
48
  def close
49
+ return unless @running || @server
50
+
67
51
  @running = false
68
- if @accept_task && !@accept_task.finished?
69
- @accept_task.stop rescue nil
70
- end
52
+ stop_accept_loop
53
+ @notification.signal_all
54
+ hang_up_clients
55
+ stop_clients
71
56
 
72
57
  @server&.close rescue nil
73
58
  @server = nil
59
+ @accept_task = nil
74
60
  File.unlink(@path) rescue nil
75
61
  end
76
62
 
77
63
  private
78
64
 
79
- def handle_client(parent_task, client)
80
- parent_task.async do
65
+ def accept_loop
66
+ while @running
81
67
  begin
82
- loop do
83
- client.read_nonblock(256)
84
- rescue IO::WaitReadable
85
- client.wait_readable
86
- retry
87
- rescue EOFError, Errno::ECONNRESET
88
- break
89
- end
90
- rescue => e
91
- Console.logger.warn(self) { "SocketWaker client handler error: #{e.class} #{e.message}" }
92
- ensure
93
- client.close rescue nil
94
- @notification.signal
68
+ client = @server.accept_nonblock
69
+ rescue IO::WaitReadable
70
+ @server.wait_readable
71
+ next
72
+ rescue Errno::EBADF, IOError
73
+ break
74
+ rescue StandardError => e
75
+ Console.logger.error(self) { "SocketWaker accept error: #{e.class} #{e.message}" }
76
+ next
95
77
  end
78
+
79
+ break unless @running
80
+
81
+ handle_client(client)
82
+ end
83
+ rescue StandardError => e
84
+ Console.logger.error(self) { "SocketWaker loop crashed: #{e.class} #{e.message}\n#{e.backtrace.join("\n")}" }
85
+ end
86
+
87
+ def handle_client(client)
88
+ @sockets[client] = true
89
+
90
+ @clients.spawn(name: 'socket-waker-client') do
91
+ loop do
92
+ client.read_nonblock(256)
93
+ @notification.signal_all
94
+ rescue IO::WaitReadable
95
+ client.wait_readable
96
+ retry
97
+ rescue EOFError, Errno::ECONNRESET, Errno::EBADF, IOError
98
+ break
99
+ end
100
+ rescue StandardError => e
101
+ Console.logger.warn(self) { "SocketWaker client handler error: #{e.class} #{e.message}" }
102
+ ensure
103
+ @sockets.delete(client)
104
+ client.close rescue nil
105
+ @notification.signal_all
106
+ end
107
+ end
108
+
109
+ def stop_accept_loop
110
+ task = @accept_task or return
111
+
112
+ wake_accept_loop
113
+ return if await(task, CLOSE_GRACE)
114
+
115
+ task.stop
116
+ await(task, CLOSE_GRACE)
117
+ end
118
+
119
+ def wake_accept_loop
120
+ return unless @server
121
+
122
+ UNIXSocket.open(@path) { |s| s.write_nonblock("\x00") rescue nil }
123
+ rescue StandardError
124
+ nil
125
+ end
126
+
127
+ def hang_up_clients
128
+ @sockets.keys.each do |socket|
129
+ socket.close
130
+ rescue StandardError
131
+ nil
96
132
  end
97
133
  end
98
134
 
135
+ def stop_clients
136
+ return if @clients.empty?
137
+
138
+ return if await_group(@clients, CLOSE_GRACE)
139
+
140
+ @clients.stop_all(CLOSE_GRACE)
141
+ end
142
+
143
+ def await(task, grace)
144
+ return true unless Runtime.scheduler
145
+
146
+ task.wait(grace)
147
+ true
148
+ rescue Runtime::TimeoutError
149
+ false
150
+ rescue Exception # rubocop:disable Lint/RescueException
151
+ true
152
+ end
153
+
154
+ def await_group(group, grace)
155
+ return true unless Runtime.scheduler
156
+
157
+ group.wait(grace)
158
+ true
159
+ rescue Runtime::TimeoutError
160
+ false
161
+ rescue StandardError
162
+ true
163
+ end
164
+
99
165
  def cleanup_stale_socket
100
166
  return unless File.exist?(@path)
101
167
 
@@ -9,7 +9,8 @@ module Async
9
9
  BUSY_TIMEOUT = 'PRAGMA busy_timeout'.freeze
10
10
  TABLE_INFO = 'PRAGMA table_info(jobs)'.freeze
11
11
  OPTIMIZE = 'PRAGMA optimize'.freeze
12
- INCREMENTAL_VACUUM = 'PRAGMA incremental_vacuum'.freeze
12
+ INCREMENTAL_VACUUM_PAGES = 64
13
+ INCREMENTAL_VACUUM = "PRAGMA incremental_vacuum(#{INCREMENTAL_VACUUM_PAGES})".freeze
13
14
  AUTO_VACUUM_INCREMENTAL = 'PRAGMA auto_vacuum = INCREMENTAL'.freeze
14
15
  BEGIN_IMMEDIATE = 'BEGIN IMMEDIATE'.freeze
15
16
  COMMIT = 'COMMIT'.freeze
@@ -22,6 +22,7 @@ module Async
22
22
  CLEANUP_INTERVAL = 300
23
23
  CLEANUP_AGE = 3600
24
24
  FAILED_RETENTION_AGE = 7 * 24 * 3600
25
+ CLEANUP_VACUUM_THRESHOLD = 100
25
26
  ERROR_MESSAGE_MAX_LEN = 2_000
26
27
  EMPTY_ARGS_JSON = '[]'.freeze
27
28
 
@@ -80,7 +81,13 @@ module Async
80
81
  def enqueue(class_name, args = EMPTY_ARGS, run_at = nil, options: EMPTY_OPTIONS)
81
82
  ensure_connection
82
83
  now = realtime_now
83
- @enqueue_stmt.execute(class_name, dump_args(args), dump_options(options), now, run_at || now)
84
+ stepped(@enqueue_stmt) do |statement|
85
+ statement.bind_param(1, class_name)
86
+ statement.bind_param(2, dump_args(args))
87
+ statement.bind_param(3, dump_options(options))
88
+ statement.bind_param(4, now)
89
+ statement.bind_param(5, run_at || now)
90
+ end
84
91
  @db.last_insert_row_id
85
92
  end
86
93
 
@@ -90,9 +97,14 @@ module Async
90
97
  now = realtime_now
91
98
 
92
99
  row = transaction do
93
- with_statement(@fetch_stmt) { |statement| statement.execute(worker_id, now, token, now).first }
100
+ stepped(@fetch_stmt) do |statement|
101
+ statement.bind_param(1, worker_id)
102
+ statement.bind_param(2, now)
103
+ statement.bind_param(3, token)
104
+ statement.bind_param(4, now)
105
+ end
94
106
  end
95
- return unless row
107
+ return if row.nil? || row.empty?
96
108
 
97
109
  maybe_cleanup
98
110
  job_from_row(row, token)
@@ -100,26 +112,28 @@ module Async
100
112
 
101
113
  def mark_started!(job_id, claim_token:, started_at: realtime_now)
102
114
  ensure_connection
103
- @mark_started_stmt.execute(started_at, job_id, claim_token)
115
+ stepped(@mark_started_stmt) do |statement|
116
+ statement.bind_param(1, started_at)
117
+ statement.bind_param(2, job_id)
118
+ statement.bind_param(3, claim_token)
119
+ end
104
120
  @db.changes.positive?
105
121
  end
106
122
 
107
123
  def complete(job_id, claim_token:, finished_at: realtime_now, duration_ms: nil)
108
124
  ensure_connection
109
- @complete_stmt.execute(finished_at, duration_ms, job_id, claim_token)
125
+ stepped(@complete_stmt) do |statement|
126
+ statement.bind_param(1, finished_at)
127
+ statement.bind_param(2, duration_ms)
128
+ statement.bind_param(3, job_id)
129
+ statement.bind_param(4, claim_token)
130
+ end
110
131
  @db.changes.positive?
111
132
  end
112
133
 
113
134
  def fail(job_id, claim_token:, error_class: nil, error_message: nil, finished_at: realtime_now, duration_ms: nil)
114
135
  ensure_connection
115
- @fail_stmt.execute(
116
- finished_at,
117
- duration_ms,
118
- error_class&.to_s,
119
- truncate_message(error_message),
120
- job_id,
121
- claim_token
122
- )
136
+ bind_failure(@fail_stmt, finished_at, duration_ms, error_class, error_message, job_id, claim_token)
123
137
  @db.changes.positive?
124
138
  end
125
139
 
@@ -146,13 +160,13 @@ module Async
146
160
 
147
161
  def recover(worker_id)
148
162
  ensure_connection
149
- @requeue_stmt.execute(worker_id)
163
+ stepped(@requeue_stmt) { |statement| statement.bind_param(1, worker_id) }
150
164
  @db.changes
151
165
  end
152
166
 
153
167
  def next_pending_run_at
154
168
  ensure_connection
155
- with_statement(@next_pending_stmt) { |statement| statement.execute.first&.first }
169
+ stepped(@next_pending_stmt)&.first
156
170
  end
157
171
 
158
172
  def data_version
@@ -243,9 +257,11 @@ module Async
243
257
  end
244
258
 
245
259
  def stored_options_for(job_id, claim_token)
246
- with_statement(@retry_state_stmt) do |statement|
247
- load_options(statement.execute(job_id, claim_token).first&.first)
260
+ row = stepped(@retry_state_stmt) do |statement|
261
+ statement.bind_param(1, job_id)
262
+ statement.bind_param(2, claim_token)
248
263
  end
264
+ load_options(row&.first)
249
265
  end
250
266
 
251
267
  def retry_policy(stored_options, fallback_options)
@@ -260,29 +276,33 @@ module Async
260
276
 
261
277
  def retry_job!(job_id, claim_token, policy, error_class, error_message)
262
278
  advanced = policy.with_attempt(policy.next_attempt)
263
- @retry_stmt.execute(
264
- realtime_now + advanced.next_retry_delay(advanced.attempt),
265
- dump_options(advanced.to_h.compact),
266
- error_class&.to_s,
267
- truncate_message(error_message),
268
- job_id,
269
- claim_token
270
- )
279
+ stepped(@retry_stmt) do |statement|
280
+ statement.bind_param(1, realtime_now + advanced.next_retry_delay(advanced.attempt))
281
+ statement.bind_param(2, dump_options(advanced.to_h.compact))
282
+ statement.bind_param(3, error_class&.to_s)
283
+ statement.bind_param(4, truncate_message(error_message))
284
+ statement.bind_param(5, job_id)
285
+ statement.bind_param(6, claim_token)
286
+ end
271
287
  @db.changes.positive? ? :retried : nil
272
288
  end
273
289
 
274
290
  def fail_job!(job_id, claim_token, error_class, error_message, finished_at, duration_ms)
275
- @fail_stmt.execute(
276
- finished_at,
277
- duration_ms,
278
- error_class&.to_s,
279
- truncate_message(error_message),
280
- job_id,
281
- claim_token
282
- )
291
+ bind_failure(@fail_stmt, finished_at, duration_ms, error_class, error_message, job_id, claim_token)
283
292
  @db.changes.positive? ? :failed : nil
284
293
  end
285
294
 
295
+ def bind_failure(statement, finished_at, duration_ms, error_class, error_message, job_id, claim_token)
296
+ stepped(statement) do |s|
297
+ s.bind_param(1, finished_at)
298
+ s.bind_param(2, duration_ms)
299
+ s.bind_param(3, error_class&.to_s)
300
+ s.bind_param(4, truncate_message(error_message))
301
+ s.bind_param(5, job_id)
302
+ s.bind_param(6, claim_token)
303
+ end
304
+ end
305
+
286
306
  def generate_claim_token = SecureRandom.hex(16)
287
307
 
288
308
  def truncate_message(message)
@@ -293,23 +313,30 @@ module Async
293
313
  end
294
314
 
295
315
  def lease_alive?(job_id, claim_token)
296
- with_statement(@lease_check_stmt) do |statement|
297
- !statement.execute(job_id, claim_token).first.nil?
298
- end
316
+ !stepped(@lease_check_stmt) do |statement|
317
+ statement.bind_param(1, job_id)
318
+ statement.bind_param(2, claim_token)
319
+ end.nil?
299
320
  end
300
321
 
301
322
  def transaction
302
- @db.execute(SQL::BEGIN_IMMEDIATE)
323
+ stepped(@begin_stmt)
303
324
  result = yield
304
- @db.execute(SQL::COMMIT)
325
+ stepped(@commit_stmt)
305
326
  result
306
327
  rescue StandardError
307
- @db.execute(SQL::ROLLBACK) rescue nil
328
+ begin
329
+ stepped(@rollback_stmt)
330
+ rescue StandardError
331
+ nil
332
+ end
308
333
  raise
309
334
  end
310
335
 
311
- def with_statement(statement)
312
- yield statement
336
+ def stepped(statement)
337
+ statement.reset!
338
+ yield statement if block_given?
339
+ statement.step
313
340
  ensure
314
341
  statement.reset! rescue nil
315
342
  end
@@ -333,19 +360,26 @@ module Async
333
360
  Job::Options.new(**options)
334
361
  end
335
362
 
363
+ STATEMENTS = {
364
+ :@enqueue_stmt => SQL::INSERT_JOB,
365
+ :@fetch_stmt => SQL::FETCH_NEXT_JOB,
366
+ :@mark_started_stmt => SQL::MARK_STARTED,
367
+ :@complete_stmt => SQL::COMPLETE_JOB,
368
+ :@fail_stmt => SQL::FAIL_JOB,
369
+ :@retry_state_stmt => SQL::RETRY_STATE,
370
+ :@lease_check_stmt => SQL::LEASE_ALIVE,
371
+ :@retry_stmt => SQL::RETRY_JOB,
372
+ :@requeue_stmt => SQL::RECOVER_WORKER,
373
+ :@cleanup_done_stmt => SQL::CLEANUP_DONE,
374
+ :@cleanup_failed_stmt => SQL::CLEANUP_FAILED,
375
+ :@next_pending_stmt => SQL::NEXT_PENDING_RUN_AT,
376
+ :@begin_stmt => SQL::BEGIN_IMMEDIATE,
377
+ :@commit_stmt => SQL::COMMIT,
378
+ :@rollback_stmt => SQL::ROLLBACK
379
+ }.freeze
380
+
336
381
  def prepare_statements
337
- @enqueue_stmt = @db.prepare(SQL::INSERT_JOB)
338
- @fetch_stmt = @db.prepare(SQL::FETCH_NEXT_JOB)
339
- @mark_started_stmt = @db.prepare(SQL::MARK_STARTED)
340
- @complete_stmt = @db.prepare(SQL::COMPLETE_JOB)
341
- @fail_stmt = @db.prepare(SQL::FAIL_JOB)
342
- @retry_state_stmt = @db.prepare(SQL::RETRY_STATE)
343
- @lease_check_stmt = @db.prepare(SQL::LEASE_ALIVE)
344
- @retry_stmt = @db.prepare(SQL::RETRY_JOB)
345
- @requeue_stmt = @db.prepare(SQL::RECOVER_WORKER)
346
- @cleanup_done_stmt = @db.prepare(SQL::CLEANUP_DONE)
347
- @cleanup_failed_stmt = @db.prepare(SQL::CLEANUP_FAILED)
348
- @next_pending_stmt = @db.prepare(SQL::NEXT_PENDING_RUN_AT)
382
+ STATEMENTS.each { |name, sql| instance_variable_set(name, @db.prepare(sql)) }
349
383
  end
350
384
 
351
385
  def finalize_statements
@@ -354,28 +388,11 @@ module Async
354
388
  end
355
389
 
356
390
  def statements
357
- [
358
- @enqueue_stmt,
359
- @fetch_stmt,
360
- @mark_started_stmt,
361
- @complete_stmt,
362
- @fail_stmt,
363
- @retry_state_stmt,
364
- @lease_check_stmt,
365
- @retry_stmt,
366
- @requeue_stmt,
367
- @cleanup_done_stmt,
368
- @cleanup_failed_stmt,
369
- @next_pending_stmt
370
- ]
391
+ STATEMENTS.keys.map { |name| instance_variable_get(name) }
371
392
  end
372
393
 
373
394
  def clear_statements
374
- @enqueue_stmt = @fetch_stmt = @mark_started_stmt = nil
375
- @complete_stmt = @fail_stmt = @retry_state_stmt = @lease_check_stmt = nil
376
- @retry_stmt = @requeue_stmt = nil
377
- @cleanup_done_stmt = @cleanup_failed_stmt = nil
378
- @next_pending_stmt = nil
395
+ STATEMENTS.each_key { |name| instance_variable_set(name, nil) }
379
396
  end
380
397
 
381
398
  def maybe_cleanup
@@ -387,9 +404,15 @@ module Async
387
404
  end
388
405
 
389
406
  def cleanup_finished_jobs(now)
390
- @cleanup_done_stmt.execute(now - CLEANUP_AGE)
391
- @cleanup_failed_stmt.execute(now - FAILED_RETENTION_AGE)
392
- @db.execute(SQL::INCREMENTAL_VACUUM) if @db.changes > 100
407
+ deleted = 0
408
+
409
+ stepped(@cleanup_done_stmt) { |statement| statement.bind_param(1, now - CLEANUP_AGE) }
410
+ deleted += @db.changes
411
+ stepped(@cleanup_failed_stmt) { |statement| statement.bind_param(1, now - FAILED_RETENTION_AGE) }
412
+ deleted += @db.changes
413
+
414
+ @db.execute(SQL::INCREMENTAL_VACUUM) if deleted > CLEANUP_VACUUM_THRESHOLD
415
+ deleted
393
416
  end
394
417
  end
395
418
  end
@@ -9,6 +9,7 @@ module Async
9
9
  private
10
10
 
11
11
  def setup_queue(queue_socket_dir, queue_db_path, queue_mmap)
12
+ @queue_saturated = false
12
13
  @listen_queue = !!queue_socket_dir && !isolated_worker?
13
14
  return unless @listen_queue
14
15
 
@@ -26,20 +27,36 @@ module Async
26
27
  recover_queue_jobs
27
28
  end
28
29
 
29
- def start_queue_listener(task)
30
- @queue_waker.start_accept_loop(task)
30
+ def start_queue_listener
31
+ @queue_waker.start_accept_loop
31
32
 
32
- task.async do
33
+ @services.spawn(name: 'queue-listener') do
33
34
  logger.info { "Async::Background queue: listening on worker #{worker_index}" }
34
35
 
36
+ failures = 0
37
+
35
38
  while running?
36
- @queue_waker.wait(timeout: next_wait_timeout)
37
- dispatch_available_queue_jobs
39
+ begin
40
+ @queue_waker.wait(timeout: next_wait_timeout)
41
+ break unless running?
42
+
43
+ dispatch_available_queue_jobs
44
+ failures = 0
45
+ rescue StandardError => error
46
+ failures += 1
47
+ backoff = [QUEUE_ERROR_BACKOFF * failures, QUEUE_POLL_INTERVAL].min
48
+ logger.error('Async::Background') do
49
+ "queue listener: #{error.class} #{error.message}; retrying in #{backoff}s"
50
+ end
51
+ shutdown.wait(backoff)
52
+ end
38
53
  end
39
54
  end
40
55
  end
41
56
 
42
57
  def next_wait_timeout
58
+ return QUEUE_POLL_INTERVAL if @queue_saturated
59
+
43
60
  next_due = @queue_store.next_pending_run_at
44
61
  return QUEUE_POLL_INTERVAL unless next_due
45
62
 
@@ -68,7 +85,7 @@ module Async
68
85
  complete_queue_job!(job, class_name, claim_token, started_at)
69
86
  rescue ConfigError => error
70
87
  record_invalid_queue_job!(job, class_name, claim_token, error)
71
- rescue ::Async::TimeoutError => error
88
+ rescue Runtime::TimeoutError => error
72
89
  handle_queue_failure(
73
90
  job,
74
91
  options,
@@ -129,11 +146,18 @@ module Async
129
146
  end
130
147
 
131
148
  def dispatch_available_queue_jobs
149
+ @queue_saturated = false
150
+
132
151
  while running?
152
+ if @jobs.size >= @semaphore.limit
153
+ @queue_saturated = true
154
+ return
155
+ end
156
+
133
157
  job = @queue_store.fetch(worker_index)
134
- break unless job
158
+ return if job.nil?
135
159
 
136
- semaphore.async { |job_task| run_queue_job(job_task, job) }
160
+ spawn_job { |job_task| run_queue_job(job_task, job) }
137
161
  end
138
162
  end
139
163
 
@@ -59,12 +59,15 @@ module Async
59
59
  end
60
60
 
61
61
  def build_entries(schedule, now)
62
- schedule.each_with_object(MinHeap.new) do |(name, config), heap|
63
- next unless assigned_worker(config, name) == worker_index
62
+ schedule
63
+ .filter_map { |name, config| entry_for(name, config, now) }
64
+ .each_with_object(MinHeap.new) { |entry, heap| heap.push(entry) }
65
+ end
64
66
 
65
- task = build_task_config(name, config)
66
- heap.push(build_entry(name, task, now))
67
- end
67
+ def entry_for(name, config, now)
68
+ return unless assigned_worker(config, name) == worker_index
69
+
70
+ build_entry(name, build_task_config(name, config), now)
68
71
  end
69
72
 
70
73
  def assigned_worker(config, name)
@@ -98,18 +101,18 @@ module Async
98
101
  end
99
102
 
100
103
  def parse_interval(name, value)
101
- value&.then do |interval|
102
- interval = interval.to_i
103
- raise ConfigError, "[#{name}] 'every' must be > 0" unless interval.positive?
104
+ return if value.nil?
104
105
 
105
- interval
106
- end
106
+ interval = value.to_i
107
+ raise ConfigError, "[#{name}] 'every' must be > 0" unless interval.positive?
108
+
109
+ interval
107
110
  end
108
111
 
109
112
  def parse_cron(name, value)
110
- value&.then do |expression|
111
- Fugit::Cron.new(expression) || raise(ConfigError, "[#{name}] invalid cron: #{expression}")
112
- end
113
+ return if value.nil?
114
+
115
+ Fugit::Cron.new(value) || raise(ConfigError, "[#{name}] invalid cron: #{value}")
113
116
  end
114
117
 
115
118
  def parse_timeout(name, config)