async-background 0.7.1 → 0.7.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 +4 -4
- data/CHANGELOG.md +5 -0
- data/README.md +48 -7
- data/async-background.gemspec +2 -1
- data/lib/async/background/job.rb +5 -3
- data/lib/async/background/metrics.rb +157 -86
- data/lib/async/background/queue/client.rb +33 -15
- data/lib/async/background/queue/options.rb +70 -0
- data/lib/async/background/queue/schema.rb +160 -0
- data/lib/async/background/queue/sql.rb +205 -0
- data/lib/async/background/queue/store.rb +270 -148
- data/lib/async/background/runner/queue_execution.rb +199 -0
- data/lib/async/background/runner/schedule.rb +127 -0
- data/lib/async/background/runner.rb +99 -231
- data/lib/async/background/version.rb +1 -1
- metadata +27 -2
|
@@ -1,38 +1,59 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require '
|
|
4
|
-
|
|
3
|
+
require 'async/barrier'
|
|
4
|
+
|
|
5
|
+
require_relative 'runner/queue_execution'
|
|
6
|
+
require_relative 'runner/schedule'
|
|
5
7
|
|
|
6
8
|
module Async
|
|
7
9
|
module Background
|
|
8
10
|
class ConfigError < StandardError; end
|
|
9
11
|
|
|
10
|
-
DEFAULT_TIMEOUT
|
|
11
|
-
MIN_SLEEP_TIME
|
|
12
|
-
MAX_JITTER
|
|
12
|
+
DEFAULT_TIMEOUT = 30
|
|
13
|
+
MIN_SLEEP_TIME = 0.1
|
|
14
|
+
MAX_JITTER = 5
|
|
13
15
|
QUEUE_POLL_INTERVAL = 5
|
|
16
|
+
MIN_QUEUE_WAIT = 0.001
|
|
14
17
|
|
|
15
18
|
class Runner
|
|
16
19
|
include Clock
|
|
17
|
-
|
|
18
|
-
|
|
20
|
+
include QueueExecution
|
|
21
|
+
include Schedule
|
|
22
|
+
|
|
23
|
+
attr_reader :logger,
|
|
24
|
+
:semaphore,
|
|
25
|
+
:heap,
|
|
26
|
+
:worker_index,
|
|
27
|
+
:total_workers,
|
|
28
|
+
:shutdown,
|
|
29
|
+
:metrics,
|
|
30
|
+
:queue_store
|
|
19
31
|
|
|
20
32
|
def initialize(
|
|
21
|
-
config_path:,
|
|
22
|
-
|
|
33
|
+
config_path:,
|
|
34
|
+
job_count: 2,
|
|
35
|
+
worker_index:,
|
|
36
|
+
total_workers:,
|
|
37
|
+
queue_socket_dir: nil,
|
|
38
|
+
queue_db_path: nil,
|
|
39
|
+
queue_mmap: true,
|
|
40
|
+
metrics_shm_path: Metrics.default_shm_path
|
|
23
41
|
)
|
|
24
|
-
@logger
|
|
25
|
-
@worker_index
|
|
42
|
+
@logger = Console.logger
|
|
43
|
+
@worker_index = worker_index
|
|
26
44
|
@total_workers = total_workers
|
|
27
|
-
@running
|
|
28
|
-
@shutdown
|
|
29
|
-
@metrics
|
|
30
|
-
|
|
45
|
+
@running = true
|
|
46
|
+
@shutdown = ::Async::Condition.new
|
|
47
|
+
@metrics = Metrics.new(
|
|
48
|
+
worker_index: worker_index,
|
|
49
|
+
total_workers: total_workers,
|
|
50
|
+
shm_path: metrics_shm_path
|
|
51
|
+
)
|
|
31
52
|
logger.info { "Async::Background worker_index=#{worker_index}/#{total_workers}, job_count=#{job_count}" }
|
|
32
53
|
|
|
33
|
-
@
|
|
34
|
-
@
|
|
35
|
-
|
|
54
|
+
@drain_barrier = ::Async::Barrier.new
|
|
55
|
+
@semaphore = ::Async::Semaphore.new(job_count, parent: @drain_barrier)
|
|
56
|
+
@heap = build_heap(config_path)
|
|
36
57
|
setup_queue(queue_socket_dir, queue_db_path, queue_mmap)
|
|
37
58
|
end
|
|
38
59
|
|
|
@@ -43,10 +64,7 @@ module Async
|
|
|
43
64
|
start_queue_listener(task) if @listen_queue
|
|
44
65
|
|
|
45
66
|
scheduler_loop(task)
|
|
46
|
-
|
|
47
|
-
semaphore.acquire {}
|
|
48
|
-
@queue_store&.close
|
|
49
|
-
@queue_waker&.close
|
|
67
|
+
drain_and_close_queue
|
|
50
68
|
end
|
|
51
69
|
end
|
|
52
70
|
|
|
@@ -54,154 +72,86 @@ module Async
|
|
|
54
72
|
return unless @running
|
|
55
73
|
|
|
56
74
|
@running = false
|
|
57
|
-
logger.info {
|
|
75
|
+
logger.info { 'Async::Background: stopping gracefully' }
|
|
58
76
|
shutdown.signal
|
|
59
77
|
@queue_waker&.signal
|
|
60
78
|
end
|
|
61
79
|
|
|
62
|
-
def running?
|
|
63
|
-
@running
|
|
64
|
-
end
|
|
80
|
+
def running? = @running
|
|
65
81
|
|
|
66
82
|
private
|
|
67
83
|
|
|
68
|
-
def
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
isolated = ENV.fetch("ISOLATION_FORKS", "").split(",").map(&:to_i)
|
|
73
|
-
return if isolated.include?(worker_index)
|
|
74
|
-
|
|
75
|
-
require_relative 'queue/store'
|
|
76
|
-
require_relative 'queue/socket_waker'
|
|
77
|
-
require_relative 'queue/client'
|
|
78
|
-
|
|
79
|
-
@listen_queue = true
|
|
80
|
-
@queue_store = Queue::Store.new(
|
|
81
|
-
path: queue_db_path || Queue::Store.default_path,
|
|
82
|
-
options: { mmap: queue_mmap }
|
|
83
|
-
)
|
|
84
|
-
|
|
85
|
-
socket_path = File.join(queue_socket_dir, "async_bg_worker_#{worker_index}.sock")
|
|
86
|
-
@queue_waker = Queue::SocketWaker.new(socket_path)
|
|
87
|
-
@queue_waker.open!
|
|
88
|
-
|
|
89
|
-
recovered = @queue_store.recover(worker_index)
|
|
90
|
-
logger.info { "Async::Background queue: recovered #{recovered} stale jobs" } if recovered > 0
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
def start_queue_listener(task)
|
|
94
|
-
@queue_waker.start_accept_loop(task)
|
|
95
|
-
|
|
96
|
-
task.async do
|
|
97
|
-
logger.info { "Async::Background queue: listening on worker #{worker_index}" }
|
|
98
|
-
|
|
99
|
-
while running?
|
|
100
|
-
@queue_waker.wait(timeout: QUEUE_POLL_INTERVAL)
|
|
84
|
+
def scheduler_loop(task)
|
|
85
|
+
loop do
|
|
86
|
+
entry = heap.peek
|
|
87
|
+
break unless entry
|
|
101
88
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
break unless job
|
|
89
|
+
wait_for_next_entry(task, entry)
|
|
90
|
+
break unless running?
|
|
105
91
|
|
|
106
|
-
|
|
107
|
-
end
|
|
108
|
-
end
|
|
92
|
+
dispatch_due_entries
|
|
109
93
|
end
|
|
110
94
|
end
|
|
111
95
|
|
|
112
|
-
def
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
options = parse_job_options(job[:options])
|
|
116
|
-
|
|
117
|
-
metrics.job_started(nil)
|
|
118
|
-
started = monotonic_now
|
|
119
|
-
job_task.with_timeout(options.timeout) { klass.perform_now(*job[:args]) }
|
|
120
|
-
duration = monotonic_now - started
|
|
121
|
-
|
|
122
|
-
metrics.job_finished(nil, duration)
|
|
123
|
-
@queue_store.complete(job[:id])
|
|
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}" }
|
|
129
|
-
rescue ::Async::TimeoutError
|
|
130
|
-
metrics.job_timed_out(nil)
|
|
131
|
-
handle_queue_failure(job, options, "timed out after #{options.timeout}s", backtrace: nil)
|
|
132
|
-
rescue => e
|
|
133
|
-
metrics.job_failed(nil, e)
|
|
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}"
|
|
96
|
+
def wait_for_next_entry(task, entry)
|
|
97
|
+
wait = [entry.next_run_at - monotonic_now, MIN_SLEEP_TIME].max
|
|
98
|
+
wait_with_shutdown(task, wait)
|
|
141
99
|
end
|
|
142
100
|
|
|
143
|
-
def
|
|
144
|
-
|
|
145
|
-
|
|
101
|
+
def dispatch_due_entries
|
|
102
|
+
now = monotonic_now
|
|
103
|
+
while (entry = heap.peek) && entry.next_run_at <= now
|
|
104
|
+
break unless running?
|
|
146
105
|
|
|
147
|
-
|
|
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}" }
|
|
106
|
+
dispatch_entry(entry)
|
|
156
107
|
end
|
|
157
108
|
end
|
|
158
109
|
|
|
159
|
-
def
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
mod.const_get(name, false)
|
|
110
|
+
def dispatch_entry(entry)
|
|
111
|
+
if entry.running
|
|
112
|
+
skip_entry(entry)
|
|
113
|
+
else
|
|
114
|
+
execute_entry(entry)
|
|
165
115
|
end
|
|
166
116
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
klass
|
|
117
|
+
entry.reschedule(monotonic_now)
|
|
118
|
+
heap.replace_top(entry)
|
|
170
119
|
end
|
|
171
120
|
|
|
172
|
-
def
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
now = monotonic_now
|
|
178
|
-
wait = [entry.next_run_at - now, MIN_SLEEP_TIME].max
|
|
179
|
-
wait_with_shutdown(task, wait)
|
|
180
|
-
break unless running?
|
|
181
|
-
|
|
182
|
-
now = monotonic_now
|
|
183
|
-
while (entry = heap.peek) && entry.next_run_at <= now
|
|
184
|
-
break unless running?
|
|
121
|
+
def skip_entry(entry)
|
|
122
|
+
logger.warn('Async::Background') { "#{entry.name}: skipped, previous run still active" }
|
|
123
|
+
metrics.job_skipped(entry)
|
|
124
|
+
end
|
|
185
125
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
126
|
+
def execute_entry(entry)
|
|
127
|
+
entry.running = true
|
|
128
|
+
semaphore.async do |job_task|
|
|
129
|
+
run_job(job_task, entry)
|
|
130
|
+
ensure
|
|
131
|
+
entry.running = false
|
|
132
|
+
end
|
|
133
|
+
end
|
|
193
134
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
135
|
+
def run_job(job_task, entry)
|
|
136
|
+
metrics_started = false
|
|
137
|
+
metrics.job_started(entry)
|
|
138
|
+
metrics_started = true
|
|
139
|
+
started_at = monotonic_now
|
|
140
|
+
job_task.with_timeout(entry.timeout) { entry.job_class.perform_now }
|
|
200
141
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
142
|
+
duration = monotonic_now - started_at
|
|
143
|
+
metrics.job_succeeded(entry, duration)
|
|
144
|
+
logger.info('Async::Background') { "#{entry.name}: completed in #{duration.round(2)}s" }
|
|
145
|
+
rescue ::Async::TimeoutError
|
|
146
|
+
metrics.job_timed_out(entry)
|
|
147
|
+
logger.error('Async::Background') { "#{entry.name}: timed out after #{entry.timeout}s" }
|
|
148
|
+
rescue StandardError => error
|
|
149
|
+
metrics.job_failed(entry, error)
|
|
150
|
+
logger.error('Async::Background') {
|
|
151
|
+
"#{entry.name}: #{error.class} #{error.message}\n#{error.backtrace.join("\n")}"
|
|
152
|
+
}
|
|
153
|
+
ensure
|
|
154
|
+
metrics.job_stopped(entry) if metrics_started
|
|
205
155
|
end
|
|
206
156
|
|
|
207
157
|
def setup_signal_handlers
|
|
@@ -232,92 +182,10 @@ module Async
|
|
|
232
182
|
rescue ::Async::TimeoutError
|
|
233
183
|
end
|
|
234
184
|
|
|
235
|
-
def
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
raise ConfigError, "Empty schedule: #{config_path}" unless raw&.any?
|
|
240
|
-
|
|
241
|
-
heap = MinHeap.new
|
|
242
|
-
now = monotonic_now
|
|
243
|
-
|
|
244
|
-
raw.each do |name, config|
|
|
245
|
-
assigned = config['worker']&.to_i || ((Zlib.crc32(name) % total_workers) + 1)
|
|
246
|
-
next unless assigned == worker_index
|
|
247
|
-
|
|
248
|
-
task_config = build_task_config(name, config)
|
|
249
|
-
jitter = rand * [task_config[:interval] || MAX_JITTER, MAX_JITTER].min
|
|
250
|
-
|
|
251
|
-
next_run_at = if task_config[:interval]
|
|
252
|
-
now + jitter + task_config[:interval]
|
|
253
|
-
else
|
|
254
|
-
now_wall = Time.now
|
|
255
|
-
wall_wait = task_config[:cron].next_time(now_wall).to_f - now_wall.to_f
|
|
256
|
-
now + jitter + [wall_wait, MIN_SLEEP_TIME].max
|
|
257
|
-
end
|
|
258
|
-
|
|
259
|
-
heap.push(Entry.new(
|
|
260
|
-
name: name,
|
|
261
|
-
job_class: task_config[:job_class],
|
|
262
|
-
interval: task_config[:interval],
|
|
263
|
-
cron: task_config[:cron],
|
|
264
|
-
timeout: task_config[:timeout],
|
|
265
|
-
next_run_at: next_run_at
|
|
266
|
-
))
|
|
267
|
-
end
|
|
268
|
-
|
|
269
|
-
heap
|
|
270
|
-
end
|
|
271
|
-
|
|
272
|
-
def build_task_config(name, config)
|
|
273
|
-
class_name = config&.dig('class').to_s.strip
|
|
274
|
-
raise ConfigError, "[#{name}] missing class" if class_name.empty?
|
|
275
|
-
|
|
276
|
-
job_class = begin
|
|
277
|
-
resolve_job_class(class_name)
|
|
278
|
-
rescue ConfigError => e
|
|
279
|
-
raise ConfigError, "[#{name}] #{e.message}"
|
|
280
|
-
end
|
|
281
|
-
|
|
282
|
-
interval = config['every']&.then { |v|
|
|
283
|
-
int = v.to_i
|
|
284
|
-
raise ConfigError, "[#{name}] 'every' must be > 0" unless int.positive?
|
|
285
|
-
int
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
cron = config['cron']&.then { |c|
|
|
289
|
-
Fugit::Cron.new(c) || raise(ConfigError, "[#{name}] invalid cron: #{c}")
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
raise ConfigError, "[#{name}] specify 'every' or 'cron'" unless interval || cron
|
|
293
|
-
|
|
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 }
|
|
301
|
-
end
|
|
302
|
-
|
|
303
|
-
def run_job(job_task, entry)
|
|
304
|
-
metrics.job_started(entry)
|
|
305
|
-
t = monotonic_now
|
|
306
|
-
job_task.with_timeout(entry.timeout) { entry.job_class.perform_now }
|
|
307
|
-
|
|
308
|
-
duration = monotonic_now - t
|
|
309
|
-
metrics.job_finished(entry, duration)
|
|
310
|
-
logger.info('Async::Background') {
|
|
311
|
-
"#{entry.name}: completed in #{duration.round(2)}s"
|
|
312
|
-
}
|
|
313
|
-
rescue ::Async::TimeoutError
|
|
314
|
-
metrics.job_timed_out(entry)
|
|
315
|
-
logger.error('Async::Background') { "#{entry.name}: timed out after #{entry.timeout}s" }
|
|
316
|
-
rescue => e
|
|
317
|
-
metrics.job_failed(entry, e)
|
|
318
|
-
logger.error('Async::Background') {
|
|
319
|
-
"#{entry.name}: #{e.class} #{e.message}\n#{e.backtrace.join("\n")}"
|
|
320
|
-
}
|
|
185
|
+
def drain_and_close_queue
|
|
186
|
+
@drain_barrier.wait
|
|
187
|
+
@queue_store&.close
|
|
188
|
+
@queue_waker&.close
|
|
321
189
|
end
|
|
322
190
|
end
|
|
323
191
|
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.7.
|
|
4
|
+
version: 0.7.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-
|
|
11
|
+
date: 2026-06-27 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: async
|
|
@@ -80,6 +80,26 @@ dependencies:
|
|
|
80
80
|
- - "~>"
|
|
81
81
|
- !ruby/object:Gem::Version
|
|
82
82
|
version: '3.12'
|
|
83
|
+
- !ruby/object:Gem::Dependency
|
|
84
|
+
name: async-utilization
|
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
|
86
|
+
requirements:
|
|
87
|
+
- - ">="
|
|
88
|
+
- !ruby/object:Gem::Version
|
|
89
|
+
version: '0.3'
|
|
90
|
+
- - "<"
|
|
91
|
+
- !ruby/object:Gem::Version
|
|
92
|
+
version: '0.5'
|
|
93
|
+
type: :development
|
|
94
|
+
prerelease: false
|
|
95
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
96
|
+
requirements:
|
|
97
|
+
- - ">="
|
|
98
|
+
- !ruby/object:Gem::Version
|
|
99
|
+
version: '0.3'
|
|
100
|
+
- - "<"
|
|
101
|
+
- !ruby/object:Gem::Version
|
|
102
|
+
version: '0.5'
|
|
83
103
|
description: A production-grade lightweight scheduler built on top of Async. Single
|
|
84
104
|
event loop with min-heap timer, skip-overlapping execution, jitter, monotonic clock
|
|
85
105
|
intervals, semaphore concurrency control, and deterministic worker sharding. Designed
|
|
@@ -102,10 +122,15 @@ files:
|
|
|
102
122
|
- lib/async/background/min_heap.rb
|
|
103
123
|
- lib/async/background/queue/client.rb
|
|
104
124
|
- lib/async/background/queue/notifier.rb
|
|
125
|
+
- lib/async/background/queue/options.rb
|
|
126
|
+
- lib/async/background/queue/schema.rb
|
|
105
127
|
- lib/async/background/queue/socket_notifier.rb
|
|
106
128
|
- lib/async/background/queue/socket_waker.rb
|
|
129
|
+
- lib/async/background/queue/sql.rb
|
|
107
130
|
- lib/async/background/queue/store.rb
|
|
108
131
|
- lib/async/background/runner.rb
|
|
132
|
+
- lib/async/background/runner/queue_execution.rb
|
|
133
|
+
- lib/async/background/runner/schedule.rb
|
|
109
134
|
- lib/async/background/version.rb
|
|
110
135
|
homepage: https://github.com/roman-haidarov/async-background
|
|
111
136
|
licenses:
|