async-background 0.6.1 → 0.6.2

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: f1890dfac1632afedbbb20c367d4d770c3037f6201bb6fac77396f07481df477
4
+ data.tar.gz: 1b435f70b2fd290bd569c0c081d19ae94250a181367ada7d3b4d157c213fd5b8
5
5
  SHA512:
6
- metadata.gz: 90225c64139b4ec18ed9bb72bf82161f9642f14f39f9031ad1d64ddf1b6a074bb2b7690722017054aac521fda6395ddcae755ed17fb264fc32620639a7a64219
7
- data.tar.gz: b7ff64e05abc6e5a9a4f0122c86f4e73b2f7f170978213b307104b96bd907384a5ab16ff61db96bb50c91bb5999cf98f0cc0318601741bd3b78357b6a0302f27
6
+ metadata.gz: 0e9c2398241b48d0cc8fe66ed0b227cca3c93a5969e7f753c0a8dbb08a41462626d73709ae9eece1a254f8567f4a9044958e96a2658a77ab8f6b11bbdb6a1c14
7
+ data.tar.gz: 702016f46a3bbaa255735defcc2746baafe66a26400d2ad9525b80d89848970143998a706ceb4644519433cfcd558d9936c0caae4f968e335a2f8b8496afdec1
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,6 +3,12 @@
3
3
  module Async
4
4
  module Background
5
5
  module Job
6
+ DEFAULT_TIMEOUT = 120
7
+
8
+ Options = Data.define(:timeout) do
9
+ def initialize(timeout: DEFAULT_TIMEOUT) = super(timeout: Integer(timeout))
10
+ end
11
+
6
12
  def self.included(base)
7
13
  base.extend(ClassMethods)
8
14
  end
@@ -12,17 +18,23 @@ module Async
12
18
  new.perform(*args)
13
19
  end
14
20
 
15
- def perform_async(*args)
16
- Async::Background::Queue.enqueue(self, *args)
21
+ def perform_async(*args, options: {})
22
+ Async::Background::Queue.enqueue(self, *args, options: options)
17
23
  end
18
24
 
19
- def perform_in(delay, *args)
20
- Async::Background::Queue.enqueue_in(delay, self, *args)
25
+ def perform_in(delay, *args, options: {})
26
+ Async::Background::Queue.enqueue_in(delay, self, *args, options: options)
21
27
  end
22
28
 
23
- def perform_at(time, *args)
24
- Async::Background::Queue.enqueue_at(time, self, *args)
29
+ def perform_at(time, *args, options: {})
30
+ Async::Background::Queue.enqueue_at(time, self, *args, options: options)
25
31
  end
32
+
33
+ def options(**values)
34
+ @options = Options.new(**values).to_h
35
+ end
36
+
37
+ def resolve_options = @options || {}
26
38
  end
27
39
 
28
40
  def perform(*args)
@@ -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,53 @@ 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 = [])
24
+ def push_in(delay, class_name, args = [], options: {})
23
25
  run_at = realtime_now + delay.to_f
24
- push(class_name, args, run_at)
26
+ push(class_name, args, run_at, options: options)
25
27
  end
26
28
 
27
- def push_at(time, class_name, args = [])
29
+ def push_at(time, class_name, args = [], options: {})
28
30
  run_at = time.respond_to?(:to_f) ? time.to_f : time
29
- push(class_name, args, run_at)
31
+ push(class_name, args, run_at, options: options)
30
32
  end
31
33
  end
32
34
 
33
35
  class << self
34
36
  attr_accessor :default_client
35
37
 
36
- def enqueue(job_class, *args)
38
+ def enqueue(job_class, *args, options: {})
37
39
  ensure_configured!
38
- default_client.push(resolve_class_name(job_class), args)
40
+ merged = build_options(job_class, options)
41
+ default_client.push(resolve_class_name(job_class), args, nil, options: merged)
39
42
  end
40
43
 
41
- def enqueue_in(delay, job_class, *args)
44
+ def enqueue_in(delay, job_class, *args, options: {})
42
45
  ensure_configured!
43
- default_client.push_in(delay, resolve_class_name(job_class), args)
46
+ merged = build_options(job_class, options)
47
+ default_client.push_in(delay, resolve_class_name(job_class), args, options: merged)
44
48
  end
45
49
 
46
- def enqueue_at(time, job_class, *args)
50
+ def enqueue_at(time, job_class, *args, options: {})
47
51
  ensure_configured!
48
- default_client.push_at(time, resolve_class_name(job_class), args)
52
+ merged = build_options(job_class, options)
53
+ default_client.push_at(time, resolve_class_name(job_class), args, options: merged)
49
54
  end
50
55
 
51
56
  private
52
57
 
58
+ def build_options(job_class, call_site_options)
59
+ merged = resolve_options(job_class).merge!(call_site_options.compact)
60
+ return EMPTY_OPTIONS if merged.empty?
61
+
62
+ Job::Options.new(**merged).to_h
63
+ end
64
+
53
65
  def ensure_configured!
54
66
  raise "Async::Background::Queue not configured" unless default_client
55
67
  end
@@ -60,6 +72,12 @@ module Async
60
72
 
61
73
  raise ArgumentError, "#{job_class} must include Async::Background::Job"
62
74
  end
75
+
76
+ def resolve_options(job_class)
77
+ return {} unless job_class.respond_to?(:resolve_options)
78
+
79
+ job_class.resolve_options.dup
80
+ end
63
81
  end
64
82
  end
65
83
  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,
@@ -45,6 +46,7 @@ module Async
45
46
  def initialize(path: self.class.default_path, mmap: true)
46
47
  @path = path
47
48
  @mmap = mmap
49
+ @pragma_sql = PRAGMAS.call(mmap ? MMAP_SIZE : 0).freeze
48
50
  @db = nil
49
51
  @schema_checked = false
50
52
  @last_cleanup_at = nil
@@ -54,17 +56,18 @@ module Async
54
56
  require_sqlite3
55
57
  db = SQLite3::Database.new(@path)
56
58
  db.execute('PRAGMA busy_timeout = 5000')
57
- db.execute_batch(PRAGMAS.call(@mmap ? MMAP_SIZE : 0))
59
+ db.execute_batch(@pragma_sql)
58
60
  db.execute_batch(SCHEMA)
59
61
  db.execute("PRAGMA wal_checkpoint(TRUNCATE)")
60
62
  db.close
61
63
  @schema_checked = true
62
64
  end
63
65
 
64
- def enqueue(class_name, args = [], run_at = nil)
66
+ def enqueue(class_name, args = [], run_at = nil, options: {})
65
67
  ensure_connection
66
68
  run_at ||= realtime_now
67
- @enqueue_stmt.execute(class_name, JSON.generate(args), realtime_now, run_at)
69
+ options_json = options.empty? ? nil : JSON.generate(options)
70
+ @enqueue_stmt.execute(class_name, JSON.generate(args), options_json, realtime_now, run_at)
68
71
  @db.last_insert_row_id
69
72
  end
70
73
 
@@ -84,7 +87,8 @@ module Async
84
87
  return unless row
85
88
 
86
89
  maybe_cleanup
87
- { id: row[0], class_name: row[1], args: JSON.parse(row[2]) }
90
+ options = row[3] ? JSON.parse(row[3], symbolize_names: true) : {}
91
+ { id: row[0], class_name: row[1], args: JSON.parse(row[2]), options: options }
88
92
  rescue
89
93
  @db.execute("ROLLBACK") rescue nil
90
94
  raise
@@ -135,10 +139,11 @@ module Async
135
139
  finalize_statements
136
140
  @db = SQLite3::Database.new(@path)
137
141
  @db.execute('PRAGMA busy_timeout = 5000')
138
- @db.execute_batch(PRAGMAS.call(@mmap ? MMAP_SIZE : 0))
142
+ @db.execute_batch(@pragma_sql)
139
143
 
140
144
  unless @schema_checked
141
145
  @db.execute_batch(SCHEMA)
146
+ @db.execute("ALTER TABLE jobs ADD COLUMN options TEXT") rescue nil
142
147
  @schema_checked = true
143
148
  end
144
149
 
@@ -148,7 +153,7 @@ module Async
148
153
 
149
154
  def prepare_statements
150
155
  @enqueue_stmt = @db.prepare(
151
- "INSERT INTO jobs (class_name, args, created_at, run_at) VALUES (?, ?, ?, ?)"
156
+ "INSERT INTO jobs (class_name, args, options, created_at, run_at) VALUES (?, ?, ?, ?, ?)"
152
157
  )
153
158
 
154
159
  @fetch_stmt = @db.prepare(<<~SQL)
@@ -160,7 +165,7 @@ module Async
160
165
  ORDER BY run_at, id
161
166
  LIMIT 1
162
167
  )
163
- RETURNING id, class_name, args
168
+ RETURNING id, class_name, args, options
164
169
  SQL
165
170
 
166
171
  @complete_stmt = @db.prepare("UPDATE jobs SET status = 'done' WHERE id = ?")
@@ -175,11 +180,11 @@ module Async
175
180
  end
176
181
 
177
182
  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)
183
+ [@enqueue_stmt, @fetch_stmt, @complete_stmt, @fail_stmt, @requeue_stmt, @cleanup_stmt].each do |s|
184
+ s&.close rescue next
182
185
  end
186
+
187
+ @enqueue_stmt = @fetch_stmt = @complete_stmt = @fail_stmt = @requeue_stmt = @cleanup_stmt = nil
183
188
  end
184
189
 
185
190
  def maybe_cleanup
@@ -193,7 +198,6 @@ module Async
193
198
  @db.execute("PRAGMA incremental_vacuum")
194
199
  end
195
200
  end
196
-
197
201
  end
198
202
  end
199
203
  end
@@ -114,11 +114,13 @@ module Async
114
114
  class_name = job[:class_name]
115
115
  args = job[:args]
116
116
  klass = resolve_job_class(class_name)
117
+ opts = Job::Options.new(**job[:options])
118
+ timeout = opts.timeout
117
119
 
118
120
  metrics.job_started(nil)
119
121
  t = monotonic_now
120
122
 
121
- job_task.with_timeout(DEFAULT_TIMEOUT) { klass.perform_now(*args) }
123
+ job_task.with_timeout(timeout) { klass.perform_now(*args) }
122
124
 
123
125
  duration = monotonic_now - t
124
126
  metrics.job_finished(nil, duration)
@@ -130,7 +132,7 @@ module Async
130
132
  rescue ::Async::TimeoutError
131
133
  metrics.job_timed_out(nil)
132
134
  @queue_store.fail(job[:id])
133
- logger.error('Async::Background') { "queue(#{class_name}): timed out" }
135
+ logger.error('Async::Background') { "queue(#{class_name}): timed out after #{timeout}s" }
134
136
  rescue => e
135
137
  metrics.job_failed(nil, e)
136
138
  @queue_store.fail(job[:id])
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Async
4
4
  module Background
5
- VERSION = '0.6.1'
5
+ VERSION = '0.6.2'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
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.6.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Roman Hajdarov
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-04-08 00:00:00.000000000 Z
11
+ date: 2026-04-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: async