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 +4 -4
- data/CHANGELOG.md +25 -0
- data/lib/async/background/job.rb +53 -12
- data/lib/async/background/queue/client.rb +37 -13
- data/lib/async/background/queue/store.rb +83 -39
- data/lib/async/background/runner.rb +49 -34
- data/lib/async/background/version.rb +1 -1
- metadata +3 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 851bdc350f52df868547200fe10a0d93b06b692b605c6e761b28262e2691a4df
|
|
4
|
+
data.tar.gz: 8eccc004e5250df5349135a979a59e52f7450ee356245e8cab17b4b6e2a9a9f6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/async/background/job.rb
CHANGED
|
@@ -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
|
-
|
|
17
|
-
|
|
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
|
|
20
|
-
|
|
62
|
+
def options(**values)
|
|
63
|
+
@options = Options.new(**values).to_h.compact
|
|
21
64
|
end
|
|
22
65
|
|
|
23
|
-
def
|
|
24
|
-
Async::Background::Queue.enqueue_at(time, self, *args)
|
|
25
|
-
end
|
|
66
|
+
def resolve_options = @options || {}
|
|
26
67
|
end
|
|
27
68
|
|
|
28
|
-
def perform(*
|
|
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
|
-
|
|
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
|
|
47
|
-
@mmap
|
|
48
|
-
@db
|
|
49
|
-
@schema_checked
|
|
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
|
|
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
|
-
|
|
67
|
-
@enqueue_stmt.execute(class_name, JSON.generate(args),
|
|
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
|
-
|
|
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
|
|
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
|
|
167
|
-
@fail_stmt
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
stmt&.close rescue
|
|
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
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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
|
-
|
|
262
|
-
rescue
|
|
263
|
-
raise ConfigError, "[#{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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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)
|
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.
|
|
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-
|
|
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.
|
|
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: []
|