async-background 0.6.1 → 0.7.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0f4392db5a752c9a07da3c140fedec1ad2234950ccce48d127eb7c4de188bfee
4
- data.tar.gz: 31d3c8f2cc5b303361c081e95162d13e7ed04917f7ae93187abd9c3b3c175445
3
+ metadata.gz: 851bdc350f52df868547200fe10a0d93b06b692b605c6e761b28262e2691a4df
4
+ data.tar.gz: 8eccc004e5250df5349135a979a59e52f7450ee356245e8cab17b4b6e2a9a9f6
5
5
  SHA512:
6
- metadata.gz: 90225c64139b4ec18ed9bb72bf82161f9642f14f39f9031ad1d64ddf1b6a074bb2b7690722017054aac521fda6395ddcae755ed17fb264fc32620639a7a64219
7
- data.tar.gz: b7ff64e05abc6e5a9a4f0122c86f4e73b2f7f170978213b307104b96bd907384a5ab16ff61db96bb50c91bb5999cf98f0cc0318601741bd3b78357b6a0302f27
6
+ metadata.gz: 7ce63c6e58d96670add9cbfc438ab5cb33d2472c3ef6d4484cde4682f3416749f946cadc1fe00333e1e6e68b1b861df8d6d9cb88a7e520f48f73ecc9b4c86265
7
+ data.tar.gz: e4f7fc4fb560dba97580f0bd1ea446ade22edb219e1fba7d725ecb4b798b2feddf76b5266f3e408516b33d880046c481ca7b0746ae08accd70a1665466143019
data/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.2
4
+
5
+ ### Features
6
+ - **Configurable timeout for queue jobs** — queue jobs previously used a hardcoded 30-second timeout (`DEFAULT_TIMEOUT`). Now configurable via `options` hash at two levels:
7
+ ```ruby
8
+ # Class-level default
9
+ class HeavyImportJob
10
+ include Async::Background::Job
11
+ options timeout: 600
12
+
13
+ def perform(user_id) = # ...
14
+ end
15
+
16
+ # Call-site override (wins over class-level)
17
+ HeavyImportJob.perform_async(user_id, options: { timeout: 120 })
18
+ ```
19
+ Priority: call-site `options:` → class-level `options` → `DEFAULT_TIMEOUT` (30s). Options are merged at enqueue time so the runner simply reads the final value from the payload
20
+ - **`options:` hash across the entire enqueue chain** — single extensible contract from `perform_async` through `Client` down to `Store`. Currently supports `:timeout`, designed to accommodate future keys (e.g. `:retry`) without API changes
21
+ - **`Job::Options` schema via `Data.define`** — declares known option keys with types and defaults. Unknown keys raise `ArgumentError`, invalid types raise `TypeError`. No manual validation code
22
+ - **`options TEXT` column in SQLite** — stores the merged options hash as JSON. Extensible without schema changes when new options are added
23
+
24
+ ### Improvements
25
+ - **Queue timeout logged on failure** — `run_queue_job` error log now includes actual timeout value: `"timed out after 120s"` instead of generic `"timed out"`
26
+ - **Idempotent schema migration** — existing databases get `ALTER TABLE jobs ADD COLUMN options TEXT` on first connection, wrapped in `rescue nil` for safe re-runs. New databases include the column in `CREATE TABLE`
27
+
3
28
  ## 0.6.1
4
29
 
5
30
  ### Bug Fixes
@@ -3,29 +3,70 @@
3
3
  module Async
4
4
  module Background
5
5
  module Job
6
+ DEFAULT_TIMEOUT = 120
7
+ BACKOFFS = %i[fixed linear exponential].freeze
8
+ DEFAULT_JITTER_FOR = { fixed: 0.0, linear: 0.0, exponential: 0.5 }.freeze
9
+
10
+ Options = Data.define(:timeout, :retry, :retry_delay, :backoff, :attempt, :jitter) do
11
+ def initialize(timeout: DEFAULT_TIMEOUT, retry: 0, retry_delay: nil, backoff: :fixed, attempt: nil, jitter: nil)
12
+ timeout = Integer(timeout)
13
+ retries = Integer(binding.local_variable_get(:retry) || 0)
14
+ retry_delay = Float(retry_delay) unless retry_delay.nil?
15
+ backoff = backoff.to_sym
16
+ attempt = Integer(attempt) unless attempt.nil?
17
+ jitter = Float(jitter) unless jitter.nil?
18
+
19
+ raise ArgumentError, "timeout must be > 0" unless timeout.positive?
20
+ raise ArgumentError, "retry must be >= 0" if retries.negative?
21
+ raise ArgumentError, "attempt must be >= 0" if attempt && attempt.negative?
22
+ raise ArgumentError, "backoff must be one of #{BACKOFFS.inspect}" unless BACKOFFS.include?(backoff)
23
+ if retries.positive?
24
+ raise ArgumentError, "retry_delay is required when retry > 0" if retry_delay.nil?
25
+ raise ArgumentError, "retry_delay must be > 0" unless retry_delay.positive?
26
+ end
27
+ raise ArgumentError, "jitter must be in [0, 1]" if jitter && !(0.0..1.0).cover?(jitter)
28
+
29
+ super(timeout:, retry: retries, retry_delay:, backoff:, attempt:, jitter:)
30
+ end
31
+
32
+ def retry? = self.retry.positive?
33
+ def attempts_made = attempt || 0
34
+ def next_attempt = attempts_made + 1
35
+ def with_attempt(n) = with(attempt: Integer(n))
36
+ def next_retry_delay(for_attempt = next_attempt, rng: Random)
37
+ raise ArgumentError, "attempt must be > 0" unless for_attempt.positive?
38
+ raise ArgumentError, "retry_delay must be configured" if retry_delay.nil?
39
+
40
+ base = case backoff
41
+ when :fixed then retry_delay
42
+ when :linear then retry_delay * for_attempt
43
+ when :exponential then retry_delay * (2**(for_attempt - 1))
44
+ end
45
+
46
+ factor = jitter || DEFAULT_JITTER_FOR[backoff]
47
+ factor.zero? ? base : base * (1 + rng.rand * factor)
48
+ end
49
+ end
50
+
6
51
  def self.included(base)
7
52
  base.extend(ClassMethods)
8
53
  end
9
54
 
10
55
  module ClassMethods
11
- def perform_now(*args)
12
- new.perform(*args)
13
- end
56
+ def perform_now(*args) = new.perform(*args)
14
57
 
15
- def perform_async(*args)
16
- Async::Background::Queue.enqueue(self, *args)
17
- end
58
+ def perform_async(*args, options: {}) = Async::Background::Queue.enqueue(self, *args, options: options)
59
+ def perform_in(delay, *args, options: {}) = Async::Background::Queue.enqueue_in(delay, self, *args, options: options)
60
+ def perform_at(time, *args, options: {}) = Async::Background::Queue.enqueue_at(time, self, *args, options: options)
18
61
 
19
- def perform_in(delay, *args)
20
- Async::Background::Queue.enqueue_in(delay, self, *args)
62
+ def options(**values)
63
+ @options = Options.new(**values).to_h.compact
21
64
  end
22
65
 
23
- def perform_at(time, *args)
24
- Async::Background::Queue.enqueue_at(time, self, *args)
25
- end
66
+ def resolve_options = @options || {}
26
67
  end
27
68
 
28
- def perform(*args)
69
+ def perform(*)
29
70
  raise NotImplementedError, "#{self.class} must implement #perform"
30
71
  end
31
72
  end
@@ -5,6 +5,8 @@ require_relative '../clock'
5
5
  module Async
6
6
  module Background
7
7
  module Queue
8
+ EMPTY_OPTIONS = {}.freeze
9
+
8
10
  class Client
9
11
  include Clock
10
12
 
@@ -13,43 +15,59 @@ module Async
13
15
  @notifier = notifier
14
16
  end
15
17
 
16
- def push(class_name, args = [], run_at = nil)
17
- id = @store.enqueue(class_name, args, run_at)
18
+ def push(class_name, args = [], run_at = nil, options: {})
19
+ id = @store.enqueue(class_name, args, run_at, options: options)
18
20
  @notifier&.notify_all
19
21
  id
20
22
  end
21
23
 
22
- def push_in(delay, class_name, args = [])
23
- run_at = realtime_now + delay.to_f
24
- push(class_name, args, run_at)
24
+ def push_in(delay, class_name, args = [], options: {})
25
+ push(class_name, args, realtime_now + delay.to_f, options: options)
25
26
  end
26
27
 
27
- def push_at(time, class_name, args = [])
28
+ def push_at(time, class_name, args = [], options: {})
28
29
  run_at = time.respond_to?(:to_f) ? time.to_f : time
29
- push(class_name, args, run_at)
30
+ push(class_name, args, run_at, options: options)
30
31
  end
31
32
  end
32
33
 
33
34
  class << self
34
35
  attr_accessor :default_client
35
36
 
36
- def enqueue(job_class, *args)
37
+ def enqueue(job_class, *args, options: {})
37
38
  ensure_configured!
38
- default_client.push(resolve_class_name(job_class), args)
39
+ default_client.push(resolve_class_name(job_class), args, nil, options: build_options(job_class, options))
39
40
  end
40
41
 
41
- def enqueue_in(delay, job_class, *args)
42
+ def enqueue_in(delay, job_class, *args, options: {})
42
43
  ensure_configured!
43
- default_client.push_in(delay, resolve_class_name(job_class), args)
44
+ default_client.push_in(delay, resolve_class_name(job_class), args, options: build_options(job_class, options))
44
45
  end
45
46
 
46
- def enqueue_at(time, job_class, *args)
47
+ def enqueue_at(time, job_class, *args, options: {})
47
48
  ensure_configured!
48
- default_client.push_at(time, resolve_class_name(job_class), args)
49
+ default_client.push_at(time, resolve_class_name(job_class), args, options: build_options(job_class, options))
49
50
  end
50
51
 
51
52
  private
52
53
 
54
+ RETRY_KEYS = %i[retry retry_delay backoff jitter].freeze
55
+ private_constant :RETRY_KEYS
56
+
57
+ def build_options(job_class, call_site)
58
+ call_site ||= {}
59
+ merged = resolve_options(job_class).merge(call_site.compact)
60
+ apply_retry_overrides!(merged, call_site)
61
+
62
+ merged.empty? ? EMPTY_OPTIONS : Job::Options.new(**merged).to_h.compact
63
+ end
64
+
65
+ def apply_retry_overrides!(merged, call_site)
66
+ return unless RETRY_KEYS.any? { |k| call_site.key?(k) && !call_site[k].nil? }
67
+
68
+ RETRY_KEYS.each { |k| merged[k] = call_site[k] if call_site.key?(k) }
69
+ end
70
+
53
71
  def ensure_configured!
54
72
  raise "Async::Background::Queue not configured" unless default_client
55
73
  end
@@ -60,6 +78,12 @@ module Async
60
78
 
61
79
  raise ArgumentError, "#{job_class} must include Async::Background::Job"
62
80
  end
81
+
82
+ def resolve_options(job_class)
83
+ return {} unless job_class.respond_to?(:resolve_options)
84
+
85
+ job_class.resolve_options.dup
86
+ end
63
87
  end
64
88
  end
65
89
  end
@@ -15,6 +15,7 @@ module Async
15
15
  id INTEGER PRIMARY KEY,
16
16
  class_name TEXT NOT NULL,
17
17
  args TEXT NOT NULL DEFAULT '[]',
18
+ options TEXT,
18
19
  status TEXT NOT NULL DEFAULT 'pending',
19
20
  created_at REAL NOT NULL,
20
21
  run_at REAL NOT NULL,
@@ -43,52 +44,41 @@ module Async
43
44
  attr_reader :path
44
45
 
45
46
  def initialize(path: self.class.default_path, mmap: true)
46
- @path = path
47
- @mmap = mmap
48
- @db = nil
49
- @schema_checked = false
47
+ @path = path
48
+ @pragma_sql = PRAGMAS.call(mmap ? MMAP_SIZE : 0).freeze
49
+ @db = nil
50
+ @schema_checked = false
50
51
  @last_cleanup_at = nil
51
52
  end
52
53
 
53
54
  def ensure_database!
54
55
  require_sqlite3
55
56
  db = SQLite3::Database.new(@path)
56
- db.execute('PRAGMA busy_timeout = 5000')
57
- db.execute_batch(PRAGMAS.call(@mmap ? MMAP_SIZE : 0))
57
+ configure_database(db)
58
58
  db.execute_batch(SCHEMA)
59
59
  db.execute("PRAGMA wal_checkpoint(TRUNCATE)")
60
60
  db.close
61
61
  @schema_checked = true
62
62
  end
63
63
 
64
- def enqueue(class_name, args = [], run_at = nil)
64
+ def enqueue(class_name, args = [], run_at = nil, options: {})
65
65
  ensure_connection
66
- run_at ||= realtime_now
67
- @enqueue_stmt.execute(class_name, JSON.generate(args), realtime_now, run_at)
66
+ now = realtime_now
67
+ @enqueue_stmt.execute(class_name, JSON.generate(args), dump_options(options), now, run_at || now)
68
68
  @db.last_insert_row_id
69
69
  end
70
70
 
71
71
  def fetch(worker_id)
72
72
  ensure_connection
73
73
  now = realtime_now
74
- @db.execute("BEGIN IMMEDIATE")
75
74
 
76
- begin
77
- results = @fetch_stmt.execute(worker_id, now, now)
78
- row = results.first
79
- ensure
80
- @fetch_stmt.reset! rescue nil
81
- end
82
-
83
- @db.execute("COMMIT")
75
+ row = transaction { with_stmt(@fetch_stmt) { |s| s.execute(worker_id, now, now).first } }
84
76
  return unless row
85
77
 
86
78
  maybe_cleanup
87
- { id: row[0], class_name: row[1], args: JSON.parse(row[2]) }
88
- rescue
89
- @db.execute("ROLLBACK") rescue nil
90
- raise
79
+ { id: row[0], class_name: row[1], args: JSON.parse(row[2]), options: load_options(row[3]) }
91
80
  end
81
+
92
82
  def complete(job_id)
93
83
  ensure_connection
94
84
  @complete_stmt.execute(job_id)
@@ -99,6 +89,24 @@ module Async
99
89
  @fail_stmt.execute(job_id)
100
90
  end
101
91
 
92
+ def retry_or_fail(job_id, fallback_options: nil)
93
+ ensure_connection
94
+
95
+ transaction do
96
+ stored = with_stmt(@retry_state_stmt) { |s| load_options(s.execute(job_id).first&.first) }
97
+ policy = stored.empty? ? normalize_options(fallback_options) : Job::Options.new(**stored)
98
+
99
+ if policy&.retry? && policy.next_attempt <= policy.retry
100
+ advanced = policy.with_attempt(policy.next_attempt)
101
+ @retry_stmt.execute(realtime_now + advanced.next_retry_delay(advanced.attempt), dump_options(advanced.to_h.compact), job_id)
102
+ :retried
103
+ else
104
+ @fail_stmt.execute(job_id)
105
+ :failed
106
+ end
107
+ end
108
+ end
109
+
102
110
  def recover(worker_id)
103
111
  ensure_connection
104
112
  @requeue_stmt.execute(worker_id)
@@ -134,11 +142,11 @@ module Async
134
142
  require_sqlite3
135
143
  finalize_statements
136
144
  @db = SQLite3::Database.new(@path)
137
- @db.execute('PRAGMA busy_timeout = 5000')
138
- @db.execute_batch(PRAGMAS.call(@mmap ? MMAP_SIZE : 0))
145
+ configure_database(@db)
139
146
 
140
147
  unless @schema_checked
141
148
  @db.execute_batch(SCHEMA)
149
+ @db.execute("ALTER TABLE jobs ADD COLUMN options TEXT") rescue nil
142
150
  @schema_checked = true
143
151
  end
144
152
 
@@ -146,9 +154,45 @@ module Async
146
154
  @last_cleanup_at = monotonic_now
147
155
  end
148
156
 
157
+ def configure_database(db)
158
+ db.execute("PRAGMA busy_timeout = 5000")
159
+ db.execute_batch(@pragma_sql)
160
+ end
161
+
162
+ def transaction
163
+ @db.execute("BEGIN IMMEDIATE")
164
+ result = yield
165
+ @db.execute("COMMIT")
166
+ result
167
+ rescue
168
+ @db.execute("ROLLBACK") rescue nil
169
+ raise
170
+ end
171
+
172
+ def with_stmt(stmt)
173
+ yield stmt
174
+ ensure
175
+ stmt.reset! rescue nil
176
+ end
177
+
178
+ def dump_options(options)
179
+ options.empty? ? nil : JSON.generate(options)
180
+ end
181
+
182
+ def load_options(json)
183
+ json ? JSON.parse(json, symbolize_names: true) : {}
184
+ end
185
+
186
+ def normalize_options(options)
187
+ return if options.nil?
188
+ return options if options.is_a?(Job::Options)
189
+
190
+ Job::Options.new(**options)
191
+ end
192
+
149
193
  def prepare_statements
150
194
  @enqueue_stmt = @db.prepare(
151
- "INSERT INTO jobs (class_name, args, created_at, run_at) VALUES (?, ?, ?, ?)"
195
+ "INSERT INTO jobs (class_name, args, options, created_at, run_at) VALUES (?, ?, ?, ?, ?)"
152
196
  )
153
197
 
154
198
  @fetch_stmt = @db.prepare(<<~SQL)
@@ -160,26 +204,30 @@ module Async
160
204
  ORDER BY run_at, id
161
205
  LIMIT 1
162
206
  )
163
- RETURNING id, class_name, args
207
+ RETURNING id, class_name, args, options
164
208
  SQL
165
209
 
166
- @complete_stmt = @db.prepare("UPDATE jobs SET status = 'done' WHERE id = ?")
167
- @fail_stmt = @db.prepare("UPDATE jobs SET status = 'failed' WHERE id = ?")
168
-
210
+ @complete_stmt = @db.prepare("UPDATE jobs SET status = 'done', locked_by = NULL, locked_at = NULL WHERE id = ?")
211
+ @fail_stmt = @db.prepare("UPDATE jobs SET status = 'failed', locked_by = NULL, locked_at = NULL WHERE id = ?")
212
+ @retry_state_stmt = @db.prepare("SELECT options FROM jobs WHERE id = ?")
213
+ @retry_stmt = @db.prepare(
214
+ "UPDATE jobs SET status = 'pending', locked_by = NULL, locked_at = NULL, run_at = ?, options = ? WHERE id = ?"
215
+ )
169
216
  @requeue_stmt = @db.prepare(
170
217
  "UPDATE jobs SET status = 'pending', locked_by = NULL, locked_at = NULL " \
171
218
  "WHERE status = 'running' AND locked_by = ?"
172
219
  )
173
-
174
220
  @cleanup_stmt = @db.prepare("DELETE FROM jobs WHERE status = 'done' AND created_at < ?")
175
221
  end
176
222
 
177
223
  def finalize_statements
178
- %i[enqueue_stmt fetch_stmt complete_stmt fail_stmt requeue_stmt cleanup_stmt].each do |name|
179
- stmt = instance_variable_get(:"@#{name}")
180
- stmt&.close rescue nil
181
- instance_variable_set(:"@#{name}", nil)
224
+ [@enqueue_stmt, @fetch_stmt, @complete_stmt, @fail_stmt,
225
+ @retry_state_stmt, @retry_stmt, @requeue_stmt, @cleanup_stmt].each do |stmt|
226
+ stmt&.close rescue next
182
227
  end
228
+
229
+ @enqueue_stmt = @fetch_stmt = @complete_stmt = @fail_stmt = nil
230
+ @retry_state_stmt = @retry_stmt = @requeue_stmt = @cleanup_stmt = nil
183
231
  end
184
232
 
185
233
  def maybe_cleanup
@@ -188,12 +236,8 @@ module Async
188
236
 
189
237
  @last_cleanup_at = now
190
238
  @cleanup_stmt.execute(realtime_now - CLEANUP_AGE)
191
-
192
- if @db.changes > 100
193
- @db.execute("PRAGMA incremental_vacuum")
194
- end
239
+ @db.execute("PRAGMA incremental_vacuum") if @db.changes > 100
195
240
  end
196
-
197
241
  end
198
242
  end
199
243
  end
@@ -69,14 +69,13 @@ module Async
69
69
  @listen_queue = false
70
70
  return unless queue_socket_dir
71
71
 
72
- # Lazy require — only loaded when queue is actually used
72
+ isolated = ENV.fetch("ISOLATION_FORKS", "").split(",").map(&:to_i)
73
+ return if isolated.include?(worker_index)
74
+
73
75
  require_relative 'queue/store'
74
76
  require_relative 'queue/socket_waker'
75
77
  require_relative 'queue/client'
76
78
 
77
- isolated = ENV.fetch("ISOLATION_FORKS", "").split(",").map(&:to_i)
78
- return if isolated.include?(worker_index)
79
-
80
79
  @listen_queue = true
81
80
  @queue_store = Queue::Store.new(
82
81
  path: queue_db_path || Queue::Store.default_path,
@@ -112,38 +111,55 @@ module Async
112
111
 
113
112
  def run_queue_job(job_task, job)
114
113
  class_name = job[:class_name]
115
- args = job[:args]
116
114
  klass = resolve_job_class(class_name)
115
+ options = parse_job_options(job[:options])
117
116
 
118
117
  metrics.job_started(nil)
119
- t = monotonic_now
120
-
121
- job_task.with_timeout(DEFAULT_TIMEOUT) { klass.perform_now(*args) }
118
+ started = monotonic_now
119
+ job_task.with_timeout(options.timeout) { klass.perform_now(*job[:args]) }
120
+ duration = monotonic_now - started
122
121
 
123
- duration = monotonic_now - t
124
122
  metrics.job_finished(nil, duration)
125
123
  @queue_store.complete(job[:id])
126
-
127
- logger.info('Async::Background') {
128
- "queue(#{class_name}): completed in #{duration.round(2)}s"
129
- }
124
+ logger.info('Async::Background') { "queue(#{class_name}): completed in #{duration.round(2)}s" }
125
+ rescue ConfigError => e
126
+ metrics.job_failed(nil, e) if options
127
+ @queue_store.fail(job[:id])
128
+ logger.error('Async::Background') { "queue(#{class_name}): #{e.class} #{e.message}" }
130
129
  rescue ::Async::TimeoutError
131
130
  metrics.job_timed_out(nil)
132
- @queue_store.fail(job[:id])
133
- logger.error('Async::Background') { "queue(#{class_name}): timed out" }
131
+ handle_queue_failure(job, options, "timed out after #{options.timeout}s", backtrace: nil)
134
132
  rescue => e
135
133
  metrics.job_failed(nil, e)
136
- @queue_store.fail(job[:id])
137
- logger.error('Async::Background') {
138
- "queue(#{class_name}): #{e.class} #{e.message}\n#{e.backtrace.join("\n")}"
139
- }
134
+ handle_queue_failure(job, options, "#{e.class} #{e.message}", backtrace: e.backtrace)
135
+ end
136
+
137
+ def parse_job_options(raw)
138
+ Job::Options.new(**(raw || {}))
139
+ rescue ArgumentError, TypeError => e
140
+ raise ConfigError, "invalid queue options: #{e.message}"
141
+ end
142
+
143
+ def handle_queue_failure(job, options, message, backtrace:)
144
+ result = @queue_store.retry_or_fail(job[:id], fallback_options: options)
145
+ class_name = job[:class_name]
146
+
147
+ if result == :retried
148
+ @queue_waker&.signal
149
+ attempt = options.next_attempt
150
+ logger.warn('Async::Background') do
151
+ "queue(#{class_name}): #{message}; retry #{attempt}/#{options.retry}"
152
+ end
153
+ else
154
+ tail = backtrace ? "\n#{backtrace.join("\n")}" : ''
155
+ logger.error('Async::Background') { "queue(#{class_name}): #{message}#{tail}" }
156
+ end
140
157
  end
141
158
 
142
159
  def resolve_job_class(class_name)
143
- raise ConfigError, "empty class name in queue job" if class_name.nil? || class_name.strip.empty?
160
+ raise ConfigError, "empty class name in queue job" if class_name.nil? || class_name.to_s.strip.empty?
144
161
 
145
- names = class_name.split("::")
146
- klass = names.reduce(Object) do |mod, name|
162
+ klass = class_name.split("::").reduce(Object) do |mod, name|
147
163
  raise ConfigError, "unknown class: #{class_name}" unless mod.const_defined?(name, false)
148
164
  mod.const_get(name, false)
149
165
  end
@@ -257,14 +273,12 @@ module Async
257
273
  class_name = config&.dig('class').to_s.strip
258
274
  raise ConfigError, "[#{name}] missing class" if class_name.empty?
259
275
 
260
- begin
261
- job_class = Object.const_get(class_name)
262
- rescue NameError
263
- raise ConfigError, "[#{name}] unknown class: #{class_name}"
276
+ job_class = begin
277
+ resolve_job_class(class_name)
278
+ rescue ConfigError => e
279
+ raise ConfigError, "[#{name}] #{e.message}"
264
280
  end
265
281
 
266
- raise ConfigError, "[#{name}] #{class_name} must include Async::Background::Job" unless job_class.respond_to?(:perform_now)
267
-
268
282
  interval = config['every']&.then { |v|
269
283
  int = v.to_i
270
284
  raise ConfigError, "[#{name}] 'every' must be > 0" unless int.positive?
@@ -277,12 +291,13 @@ module Async
277
291
 
278
292
  raise ConfigError, "[#{name}] specify 'every' or 'cron'" unless interval || cron
279
293
 
280
- {
281
- job_class: job_class,
282
- interval: interval,
283
- cron: cron,
284
- timeout: config.fetch('timeout', DEFAULT_TIMEOUT).to_i
285
- }
294
+ timeout = begin
295
+ Job::Options.new(timeout: config.fetch('timeout', DEFAULT_TIMEOUT)).timeout
296
+ rescue ArgumentError, TypeError => e
297
+ raise ConfigError, "[#{name}] #{e.message}"
298
+ end
299
+
300
+ { job_class: job_class, interval: interval, cron: cron, timeout: timeout }
286
301
  end
287
302
 
288
303
  def run_job(job_task, entry)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Async
4
4
  module Background
5
- VERSION = '0.6.1'
5
+ VERSION = '0.7.0'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: async-background
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.1
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Roman Hajdarov
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2026-04-08 00:00:00.000000000 Z
10
+ date: 2026-04-23 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: async
@@ -114,7 +113,6 @@ metadata:
114
113
  source_code_uri: https://github.com/roman-haidarov/async-background
115
114
  changelog_uri: https://github.com/roman-haidarov/async-background/blob/main/CHANGELOG.md
116
115
  bug_tracker_uri: https://github.com/roman-haidarov/async-background/issues
117
- post_install_message:
118
116
  rdoc_options: []
119
117
  require_paths:
120
118
  - lib
@@ -129,8 +127,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
129
127
  - !ruby/object:Gem::Version
130
128
  version: '0'
131
129
  requirements: []
132
- rubygems_version: 3.3.27
133
- signing_key:
130
+ rubygems_version: 3.6.2
134
131
  specification_version: 4
135
132
  summary: Lightweight heap-based cron/interval scheduler for Async.
136
133
  test_files: []