async-background 0.6.2 → 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: f1890dfac1632afedbbb20c367d4d770c3037f6201bb6fac77396f07481df477
4
- data.tar.gz: 1b435f70b2fd290bd569c0c081d19ae94250a181367ada7d3b4d157c213fd5b8
3
+ metadata.gz: 851bdc350f52df868547200fe10a0d93b06b692b605c6e761b28262e2691a4df
4
+ data.tar.gz: 8eccc004e5250df5349135a979a59e52f7450ee356245e8cab17b4b6e2a9a9f6
5
5
  SHA512:
6
- metadata.gz: 0e9c2398241b48d0cc8fe66ed0b227cca3c93a5969e7f753c0a8dbb08a41462626d73709ae9eece1a254f8567f4a9044958e96a2658a77ab8f6b11bbdb6a1c14
7
- data.tar.gz: 702016f46a3bbaa255735defcc2746baafe66a26400d2ad9525b80d89848970143998a706ceb4644519433cfcd558d9936c0caae4f968e335a2f8b8496afdec1
6
+ metadata.gz: 7ce63c6e58d96670add9cbfc438ab5cb33d2472c3ef6d4484cde4682f3416749f946cadc1fe00333e1e6e68b1b861df8d6d9cb88a7e520f48f73ecc9b4c86265
7
+ data.tar.gz: e4f7fc4fb560dba97580f0bd1ea446ade22edb219e1fba7d725ecb4b798b2feddf76b5266f3e408516b33d880046c481ca7b0746ae08accd70a1665466143019
@@ -4,9 +4,48 @@ module Async
4
4
  module Background
5
5
  module Job
6
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
7
9
 
8
- Options = Data.define(:timeout) do
9
- def initialize(timeout: DEFAULT_TIMEOUT) = super(timeout: Integer(timeout))
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
10
49
  end
11
50
 
12
51
  def self.included(base)
@@ -14,30 +53,20 @@ module Async
14
53
  end
15
54
 
16
55
  module ClassMethods
17
- def perform_now(*args)
18
- new.perform(*args)
19
- end
20
-
21
- def perform_async(*args, options: {})
22
- Async::Background::Queue.enqueue(self, *args, options: options)
23
- end
56
+ def perform_now(*args) = new.perform(*args)
24
57
 
25
- def perform_in(delay, *args, options: {})
26
- Async::Background::Queue.enqueue_in(delay, self, *args, options: options)
27
- end
28
-
29
- def perform_at(time, *args, options: {})
30
- Async::Background::Queue.enqueue_at(time, self, *args, options: options)
31
- 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)
32
61
 
33
62
  def options(**values)
34
- @options = Options.new(**values).to_h
63
+ @options = Options.new(**values).to_h.compact
35
64
  end
36
65
 
37
66
  def resolve_options = @options || {}
38
67
  end
39
68
 
40
- def perform(*args)
69
+ def perform(*)
41
70
  raise NotImplementedError, "#{self.class} must implement #perform"
42
71
  end
43
72
  end
@@ -22,8 +22,7 @@ module Async
22
22
  end
23
23
 
24
24
  def push_in(delay, class_name, args = [], options: {})
25
- run_at = realtime_now + delay.to_f
26
- push(class_name, args, run_at, options: options)
25
+ push(class_name, args, realtime_now + delay.to_f, options: options)
27
26
  end
28
27
 
29
28
  def push_at(time, class_name, args = [], options: {})
@@ -37,29 +36,36 @@ module Async
37
36
 
38
37
  def enqueue(job_class, *args, options: {})
39
38
  ensure_configured!
40
- merged = build_options(job_class, options)
41
- default_client.push(resolve_class_name(job_class), args, nil, options: merged)
39
+ default_client.push(resolve_class_name(job_class), args, nil, options: build_options(job_class, options))
42
40
  end
43
41
 
44
42
  def enqueue_in(delay, job_class, *args, options: {})
45
43
  ensure_configured!
46
- merged = build_options(job_class, options)
47
- default_client.push_in(delay, resolve_class_name(job_class), args, options: merged)
44
+ default_client.push_in(delay, resolve_class_name(job_class), args, options: build_options(job_class, options))
48
45
  end
49
46
 
50
47
  def enqueue_at(time, job_class, *args, options: {})
51
48
  ensure_configured!
52
- merged = build_options(job_class, options)
53
- default_client.push_at(time, resolve_class_name(job_class), args, options: merged)
49
+ default_client.push_at(time, resolve_class_name(job_class), args, options: build_options(job_class, options))
54
50
  end
55
51
 
56
52
  private
57
53
 
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?
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? }
61
67
 
62
- Job::Options.new(**merged).to_h
68
+ RETRY_KEYS.each { |k| merged[k] = call_site[k] if call_site.key?(k) }
63
69
  end
64
70
 
65
71
  def ensure_configured!
@@ -44,19 +44,17 @@ module Async
44
44
  attr_reader :path
45
45
 
46
46
  def initialize(path: self.class.default_path, mmap: true)
47
- @path = path
48
- @mmap = mmap
49
- @pragma_sql = PRAGMAS.call(mmap ? MMAP_SIZE : 0).freeze
50
- @db = nil
51
- @schema_checked = false
47
+ @path = path
48
+ @pragma_sql = PRAGMAS.call(mmap ? MMAP_SIZE : 0).freeze
49
+ @db = nil
50
+ @schema_checked = false
52
51
  @last_cleanup_at = nil
53
52
  end
54
53
 
55
54
  def ensure_database!
56
55
  require_sqlite3
57
56
  db = SQLite3::Database.new(@path)
58
- db.execute('PRAGMA busy_timeout = 5000')
59
- db.execute_batch(@pragma_sql)
57
+ configure_database(db)
60
58
  db.execute_batch(SCHEMA)
61
59
  db.execute("PRAGMA wal_checkpoint(TRUNCATE)")
62
60
  db.close
@@ -65,34 +63,22 @@ module Async
65
63
 
66
64
  def enqueue(class_name, args = [], run_at = nil, options: {})
67
65
  ensure_connection
68
- run_at ||= realtime_now
69
- options_json = options.empty? ? nil : JSON.generate(options)
70
- @enqueue_stmt.execute(class_name, JSON.generate(args), options_json, realtime_now, run_at)
66
+ now = realtime_now
67
+ @enqueue_stmt.execute(class_name, JSON.generate(args), dump_options(options), now, run_at || now)
71
68
  @db.last_insert_row_id
72
69
  end
73
70
 
74
71
  def fetch(worker_id)
75
72
  ensure_connection
76
73
  now = realtime_now
77
- @db.execute("BEGIN IMMEDIATE")
78
-
79
- begin
80
- results = @fetch_stmt.execute(worker_id, now, now)
81
- row = results.first
82
- ensure
83
- @fetch_stmt.reset! rescue nil
84
- end
85
74
 
86
- @db.execute("COMMIT")
75
+ row = transaction { with_stmt(@fetch_stmt) { |s| s.execute(worker_id, now, now).first } }
87
76
  return unless row
88
77
 
89
78
  maybe_cleanup
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 }
92
- rescue
93
- @db.execute("ROLLBACK") rescue nil
94
- raise
79
+ { id: row[0], class_name: row[1], args: JSON.parse(row[2]), options: load_options(row[3]) }
95
80
  end
81
+
96
82
  def complete(job_id)
97
83
  ensure_connection
98
84
  @complete_stmt.execute(job_id)
@@ -103,6 +89,24 @@ module Async
103
89
  @fail_stmt.execute(job_id)
104
90
  end
105
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
+
106
110
  def recover(worker_id)
107
111
  ensure_connection
108
112
  @requeue_stmt.execute(worker_id)
@@ -138,8 +142,7 @@ module Async
138
142
  require_sqlite3
139
143
  finalize_statements
140
144
  @db = SQLite3::Database.new(@path)
141
- @db.execute('PRAGMA busy_timeout = 5000')
142
- @db.execute_batch(@pragma_sql)
145
+ configure_database(@db)
143
146
 
144
147
  unless @schema_checked
145
148
  @db.execute_batch(SCHEMA)
@@ -151,6 +154,42 @@ module Async
151
154
  @last_cleanup_at = monotonic_now
152
155
  end
153
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
+
154
193
  def prepare_statements
155
194
  @enqueue_stmt = @db.prepare(
156
195
  "INSERT INTO jobs (class_name, args, options, created_at, run_at) VALUES (?, ?, ?, ?, ?)"
@@ -168,23 +207,27 @@ module Async
168
207
  RETURNING id, class_name, args, options
169
208
  SQL
170
209
 
171
- @complete_stmt = @db.prepare("UPDATE jobs SET status = 'done' WHERE id = ?")
172
- @fail_stmt = @db.prepare("UPDATE jobs SET status = 'failed' WHERE id = ?")
173
-
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
+ )
174
216
  @requeue_stmt = @db.prepare(
175
217
  "UPDATE jobs SET status = 'pending', locked_by = NULL, locked_at = NULL " \
176
218
  "WHERE status = 'running' AND locked_by = ?"
177
219
  )
178
-
179
220
  @cleanup_stmt = @db.prepare("DELETE FROM jobs WHERE status = 'done' AND created_at < ?")
180
221
  end
181
222
 
182
223
  def finalize_statements
183
- [@enqueue_stmt, @fetch_stmt, @complete_stmt, @fail_stmt, @requeue_stmt, @cleanup_stmt].each do |s|
184
- s&.close rescue next
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
185
227
  end
186
228
 
187
- @enqueue_stmt = @fetch_stmt = @complete_stmt = @fail_stmt = @requeue_stmt = @cleanup_stmt = nil
229
+ @enqueue_stmt = @fetch_stmt = @complete_stmt = @fail_stmt = nil
230
+ @retry_state_stmt = @retry_stmt = @requeue_stmt = @cleanup_stmt = nil
188
231
  end
189
232
 
190
233
  def maybe_cleanup
@@ -193,10 +236,7 @@ module Async
193
236
 
194
237
  @last_cleanup_at = now
195
238
  @cleanup_stmt.execute(realtime_now - CLEANUP_AGE)
196
-
197
- if @db.changes > 100
198
- @db.execute("PRAGMA incremental_vacuum")
199
- end
239
+ @db.execute("PRAGMA incremental_vacuum") if @db.changes > 100
200
240
  end
201
241
  end
202
242
  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,40 +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)
117
- opts = Job::Options.new(**job[:options])
118
- timeout = opts.timeout
115
+ options = parse_job_options(job[:options])
119
116
 
120
117
  metrics.job_started(nil)
121
- t = monotonic_now
122
-
123
- job_task.with_timeout(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
124
121
 
125
- duration = monotonic_now - t
126
122
  metrics.job_finished(nil, duration)
127
123
  @queue_store.complete(job[:id])
128
-
129
- logger.info('Async::Background') {
130
- "queue(#{class_name}): completed in #{duration.round(2)}s"
131
- }
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}" }
132
129
  rescue ::Async::TimeoutError
133
130
  metrics.job_timed_out(nil)
134
- @queue_store.fail(job[:id])
135
- logger.error('Async::Background') { "queue(#{class_name}): timed out after #{timeout}s" }
131
+ handle_queue_failure(job, options, "timed out after #{options.timeout}s", backtrace: nil)
136
132
  rescue => e
137
133
  metrics.job_failed(nil, e)
138
- @queue_store.fail(job[:id])
139
- logger.error('Async::Background') {
140
- "queue(#{class_name}): #{e.class} #{e.message}\n#{e.backtrace.join("\n")}"
141
- }
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
142
157
  end
143
158
 
144
159
  def resolve_job_class(class_name)
145
- 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?
146
161
 
147
- names = class_name.split("::")
148
- klass = names.reduce(Object) do |mod, name|
162
+ klass = class_name.split("::").reduce(Object) do |mod, name|
149
163
  raise ConfigError, "unknown class: #{class_name}" unless mod.const_defined?(name, false)
150
164
  mod.const_get(name, false)
151
165
  end
@@ -259,14 +273,12 @@ module Async
259
273
  class_name = config&.dig('class').to_s.strip
260
274
  raise ConfigError, "[#{name}] missing class" if class_name.empty?
261
275
 
262
- begin
263
- job_class = Object.const_get(class_name)
264
- rescue NameError
265
- 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}"
266
280
  end
267
281
 
268
- raise ConfigError, "[#{name}] #{class_name} must include Async::Background::Job" unless job_class.respond_to?(:perform_now)
269
-
270
282
  interval = config['every']&.then { |v|
271
283
  int = v.to_i
272
284
  raise ConfigError, "[#{name}] 'every' must be > 0" unless int.positive?
@@ -279,12 +291,13 @@ module Async
279
291
 
280
292
  raise ConfigError, "[#{name}] specify 'every' or 'cron'" unless interval || cron
281
293
 
282
- {
283
- job_class: job_class,
284
- interval: interval,
285
- cron: cron,
286
- timeout: config.fetch('timeout', DEFAULT_TIMEOUT).to_i
287
- }
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 }
288
301
  end
289
302
 
290
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.2'
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.2
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-16 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: []